From dc037f0d79389d52f81c54a2c5177e0b24700251 Mon Sep 17 00:00:00 2001
From: kiannidev
Date: Thu, 12 Mar 2026 02:22:04 +0200
Subject: [PATCH 01/44] fix(telegram): stop typing indicator when LLM fails or
hangs
---
pkg/agent/loop.go | 5 +++++
pkg/channels/manager.go | 13 +++++++++++
pkg/channels/manager_test.go | 37 +++++++++++++++++++++++++++++++
pkg/channels/telegram/telegram.go | 12 +++++++++-
4 files changed, 66 insertions(+), 1 deletion(-)
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index 28e549ce0..4860b9e2a 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -255,6 +255,11 @@ func (al *AgentLoop) Run(ctx context.Context) error {
// Process message
func() {
+ defer func() {
+ if al.channelManager != nil {
+ al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID)
+ }
+ }()
// TODO: Re-enable media cleanup after inbound media is properly consumed by the agent.
// Currently disabled because files are deleted before the LLM can access their content.
// defer func() {
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 472895a7a..2c06feb38 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -130,6 +130,19 @@ func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
m.typingStops.Store(key, typingEntry{stop: stop, createdAt: time.Now()})
}
+// InvokeTypingStop invokes the registered typing stop function for the given channel and chatID.
+// It is safe to call even when no typing indicator is active (no-op).
+// Used by the agent loop to stop typing when processing completes (success, error, or panic),
+// regardless of whether an outbound message is published.
+func (m *Manager) InvokeTypingStop(channel, chatID string) {
+ key := channel + ":" + chatID
+ if v, loaded := m.typingStops.LoadAndDelete(key); loaded {
+ if entry, ok := v.(typingEntry); ok {
+ entry.stop()
+ }
+ }
+}
+
// RecordReactionUndo registers a reaction undo function for later invocation.
// Implements PlaceholderRecorder.
func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) {
diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go
index 1f3a628c2..f92e4abb3 100644
--- a/pkg/channels/manager_test.go
+++ b/pkg/channels/manager_test.go
@@ -511,6 +511,43 @@ func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) {
}
}
+func TestInvokeTypingStop_CallsRegisteredStop(t *testing.T) {
+ m := newTestManager()
+ var stopCalled bool
+
+ m.RecordTypingStop("telegram", "chat123", func() {
+ stopCalled = true
+ })
+
+ m.InvokeTypingStop("telegram", "chat123")
+
+ if !stopCalled {
+ t.Fatal("expected typing stop func to be called")
+ }
+}
+
+func TestInvokeTypingStop_NoOpWhenNoEntry(t *testing.T) {
+ m := newTestManager()
+ // Should not panic
+ m.InvokeTypingStop("telegram", "nonexistent")
+}
+
+func TestInvokeTypingStop_Idempotent(t *testing.T) {
+ m := newTestManager()
+ var callCount int
+
+ m.RecordTypingStop("telegram", "chat123", func() {
+ callCount++
+ })
+
+ m.InvokeTypingStop("telegram", "chat123")
+ m.InvokeTypingStop("telegram", "chat123") // Second call: entry already removed, no-op
+
+ if callCount != 1 {
+ t.Fatalf("expected stop to be called once, got %d", callCount)
+ }
+}
+
func TestPreSend_TypingStopCalled(t *testing.T) {
m := newTestManager()
var stopCalled bool
diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go
index 34ee46b7b..5f86d24c9 100644
--- a/pkg/channels/telegram/telegram.go
+++ b/pkg/channels/telegram/telegram.go
@@ -242,10 +242,17 @@ func (c *TelegramChannel) sendHTMLChunk(
return nil
}
+// maxTypingDuration limits how long the typing indicator can run.
+// Prevents endless typing when the LLM fails/hangs and preSend never invokes cancel.
+// Matches channels.Manager's typingStopTTL (5 min) so behavior is consistent.
+const maxTypingDuration = 5 * time.Minute
+
// StartTyping implements channels.TypingCapable.
// It sends ChatAction(typing) immediately and then repeats every 4 seconds
// (Telegram's typing indicator expires after ~5s) in a background goroutine.
// The returned stop function is idempotent and cancels the goroutine.
+// The goroutine also exits automatically after maxTypingDuration if cancel is
+// never called (e.g. when the LLM fails or times out without publishing).
func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
cid, threadID, err := parseTelegramChatID(chatID)
if err != nil {
@@ -259,12 +266,15 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
_ = c.bot.SendChatAction(ctx, action)
typingCtx, cancel := context.WithCancel(ctx)
+ // Cap lifetime so the goroutine cannot run indefinitely if cancel is never called
+ maxCtx, maxCancel := context.WithTimeout(typingCtx, maxTypingDuration)
go func() {
+ defer maxCancel()
ticker := time.NewTicker(4 * time.Second)
defer ticker.Stop()
for {
select {
- case <-typingCtx.Done():
+ case <-maxCtx.Done():
return
case <-ticker.C:
a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
From 7673b626b3d23025e820e87ea7630e2fad5b7237 Mon Sep 17 00:00:00 2001
From: Mauro
Date: Thu, 19 Mar 2026 11:08:50 +0100
Subject: [PATCH 02/44] feat(tool): debug tool usage via channels (#1332)
* feat(tool): debug usage via channel
* set defaults
* fix conflicts
---
config/config.example.json | 6 +++-
docs/debug.md | 66 ++++++++++++++++++++++++++++++++++++++
pkg/agent/loop.go | 16 +++++++++
pkg/config/config.go | 54 ++++++++++++++++++++++---------
pkg/config/defaults.go | 4 +++
5 files changed, 129 insertions(+), 17 deletions(-)
diff --git a/config/config.example.json b/config/config.example.json
index c214f26fa..6df0a6293 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -8,7 +8,11 @@
"temperature": 0.7,
"max_tool_iterations": 20,
"summarize_message_threshold": 20,
- "summarize_token_percent": 75
+ "summarize_token_percent": 75,
+ "tool_feedback": {
+ "enabled": false,
+ "max_args_length": 300
+ }
}
},
"model_list": [
diff --git a/docs/debug.md b/docs/debug.md
index 7e28a15f2..b9e776f0f 100644
--- a/docs/debug.md
+++ b/docs/debug.md
@@ -31,3 +31,69 @@ When this flag is active, the global truncation function is disabled. This is ex
* Verifying the exact syntax of the messages sent to the provider.
* Reading the complete output of tools like `exec`, `web_fetch`, or `read_file`.
* Debugging the session history saved in memory.
+
+## Tool Call Visibility in Debug Logs
+
+When debug mode is active, the agent emits structured log entries at each stage of the tool execution lifecycle. These entries carry a `component=agent` label and use `INFO` or `DEBUG` level depending on the amount of detail:
+
+| Log message | Level | Key fields | Description |
+|---|---|---|---|
+| `LLM requested tool calls` | INFO | `tools`, `count`, `iteration` | List of tool names the model decided to call |
+| `Tool call: ()` | INFO | `tool`, `iteration` | The tool name and a preview of its arguments (truncated to 200 chars) |
+| `Sent tool result to user` | DEBUG | `tool`, `content_len` | Fired when a tool result is forwarded to the chat channel |
+| `TTL tick after tool execution` | DEBUG | `agent_id`, `iteration` | MCP tool-discovery TTL decrement after each tool round |
+| `Async tool completed, publishing result` | INFO | `tool`, `content_len`, `channel` | Only for tools that run asynchronously in the background |
+
+### Reading a tool call log entry
+
+A typical synchronous tool call produces two consecutive lines in the console:
+
+```
+[...] [INFO] agent: LLM requested tool calls {tools=[web_search], count=1, iteration=1}
+[...] [INFO] agent: Tool call: web_search({"query":"picoclaw release notes"}) {tool=web_search, iteration=1}
+```
+
+The arguments preview is hard-capped at **200 characters** in the logs regardless of the `--no-truncate` flag, because it belongs to the `INFO`-level path. Use `--no-truncate` together with `--debug` to see the full `tools_json` field emitted by the `Full LLM request` DEBUG entry, which contains every tool definition sent to the model.
+
+## Real-Time Tool Feedback in Chat (tool_feedback)
+
+Debug logs are server-side only. If you want the agent to send a visible notification directly into the chat channel every time it executes a tool—useful when sharing the bot with other users or for transparency—enable the `tool_feedback` feature in `config.json`:
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "tool_feedback": {
+ "enabled": true,
+ "max_args_length": 300
+ }
+ }
+ }
+}
+```
+
+When `enabled` is `true`, every tool call sends a short message to the chat before the tool result is returned to the model. The message looks like:
+
+```bash
+🔧 `web_search`
+{"query": "picoclaw release notes"}
+```
+
+
+### Options
+
+| Field | Type | Default | Description |
+|---|---|---|---|
+| `enabled` | bool | `false` | Send a chat notification for each tool call |
+| `max_args_length` | int | `300` | Maximum characters of the serialised arguments included in the notification |
+
+### Environment variables
+
+Both fields can also be set via environment variables:
+
+```bash
+PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED=true
+PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH=300
+```
+
+> **Note:** `tool_feedback` is independent of `--debug` mode. It works in production and does not require the gateway to be started with any special flag.
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index a6eccc3fe..edb0994c2 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -1322,6 +1322,22 @@ func (al *AgentLoop) runLLMIteration(
"iteration": iteration,
})
+ // Send tool feedback to chat channel if enabled
+ if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && opts.Channel != "" {
+ feedbackPreview := utils.Truncate(
+ string(argsJSON),
+ al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
+ )
+ feedbackMsg := fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", tc.Name, feedbackPreview)
+ fbCtx, fbCancel := context.WithTimeout(ctx, 3*time.Second)
+ _ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{
+ Channel: opts.Channel,
+ ChatID: opts.ChatID,
+ Content: feedbackMsg,
+ })
+ fbCancel()
+ }
+
// Create async callback for tools that implement AsyncExecutor.
// When the background work completes, this publishes the result
// as an inbound system message so processSystemMessage routes it
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 78b3aa487..947af14a6 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -219,23 +219,32 @@ type RoutingConfig struct {
Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model
}
+// ToolFeedbackConfig controls whether tool execution details are sent to the
+// chat channel as real-time feedback messages. When enabled, every tool call
+// produces a short notification with the tool name and its parameters.
+type ToolFeedbackConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"`
+ MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"`
+}
+
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"`
- Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
- ModelFallbacks []string `json:"model_fallbacks,omitempty"`
- 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"`
- 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"`
+ 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"`
+ Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
+ ModelFallbacks []string `json:"model_fallbacks,omitempty"`
+ 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"`
+ 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"`
+ ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
}
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
@@ -247,6 +256,19 @@ func (d *AgentDefaults) GetMaxMediaSize() int {
return DefaultMaxMediaSize
}
+// GetToolFeedbackMaxArgsLength returns the max args preview length for tool feedback messages.
+func (d *AgentDefaults) GetToolFeedbackMaxArgsLength() int {
+ if d.ToolFeedback.MaxArgsLength > 0 {
+ return d.ToolFeedback.MaxArgsLength
+ }
+ return 300
+}
+
+// IsToolFeedbackEnabled returns true when tool feedback messages should be sent to the chat.
+func (d *AgentDefaults) IsToolFeedbackEnabled() bool {
+ return d.ToolFeedback.Enabled
+}
+
// GetModelName returns the effective model name for the agent defaults.
// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
func (d *AgentDefaults) GetModelName() string {
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 5841504aa..4038696c4 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -35,6 +35,10 @@ func DefaultConfig() *Config {
MaxToolIterations: 50,
SummarizeMessageThreshold: 20,
SummarizeTokenPercent: 75,
+ ToolFeedback: ToolFeedbackConfig{
+ Enabled: true,
+ MaxArgsLength: 300,
+ },
},
},
Bindings: []AgentBinding{},
From 9a25fad20a600988573f6fdabe21b0c8146704da Mon Sep 17 00:00:00 2001
From: ZHANG RUI
Date: Thu, 19 Mar 2026 20:06:51 +0800
Subject: [PATCH 03/44] Implement the latest long-connection mode for the WeCom
AI Bot. (#1295)
* feat(wecom): add WebSocket long-connection support for WeCom AI Bot
- Introduced WeComAIBotWSChannel to handle WebSocket connections.
- Updated NewWeComAIBotChannel to prioritize WebSocket mode when BotID and Secret are provided.
- Enhanced WeComAIBotConfig to include BotID and Secret for WebSocket mode.
- Implemented message handling for text, image, voice, and mixed messages in WebSocket mode.
- Added tests for WebSocket mode functionality and ensured backward compatibility with webhook mode.
- Refactored existing code to improve clarity and maintainability.
* feat(wecom): implement periodic processing hints and enforce WeCom stream deadline
* feat(wecom): update WeCom AI Bot setup instructions and configuration parameters
* feat(wecom): enhance WeCom AI Bot with image handling and media support
* feat(wecom): refactor WeCom AI Bot task management to use req_id for concurrent message handling
* feat(wecom): refactor WeCom AI Bot to manage request states and late replies
* feat(wecom): add response timeout handling and improve WebSocket command acknowledgment
* fix(wecom): improve error handling for late reply proactive push delivery
* refactor(wecom): reorganize WeCom AI Bot configuration fields for improved readability
* fix(wecom): update error message for websocket delivery failure in late reply proactive push
* feat(wecom): implement shared HTTP clients for WeCom image handling and response URL posting
* refactor(wecom): simplify image download and storage process in storeWSImage
* fix(wecom): improve error logging for WebSocket message handling and proactive push delivery
* fix(wecom): enhance WebSocket connection stability and task cancellation handling
* fix(wecom): improve WS image message handling by ensuring proper error response and initializing mediaRefs
* feat(wecom): enhance WeCom AIBot WebSocket handling with message deduplication and support for file and video messages
* refactor(wecom): rename image handling functions to media handling and enhance media type support
* feat(wecom): implement byte-aware content splitting for WeCom AI Bot stream messages
* refactor(wecom): remove max message length constraint from WeCom AIBot WS channel
---
README.md | 513 ++++++-
config/config.example.json | 2 +
docs/channels/wecom/wecom_aibot/README.zh.md | 66 +-
pkg/channels/manager.go | 4 +-
pkg/channels/wecom/aibot.go | 101 +-
pkg/channels/wecom/aibot_test.go | 315 +++-
pkg/channels/wecom/aibot_ws.go | 1346 ++++++++++++++++++
pkg/channels/wecom/aibot_ws_test.go | 295 ++++
pkg/config/config.go | 20 +-
9 files changed, 2539 insertions(+), 123 deletions(-)
create mode 100644 pkg/channels/wecom/aibot_ws.go
create mode 100644 pkg/channels/wecom/aibot_ws_test.go
diff --git a/README.md b/README.md
index 2420df864..2aa3b631f 100644
--- a/README.md
+++ b/README.md
@@ -191,15 +191,510 @@ make install
For detailed guides, see the docs below. The README covers quick start only.
-| Topic | Description |
-|-------|-------------|
-| 🐳 [Docker & Quick Start](docs/docker.md) | Docker Compose setup, Launcher/Agent modes, Quick Start configuration |
-| 💬 [Chat Apps](docs/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom, and more |
-| ⚙️ [Configuration](docs/configuration.md) | Environment variables, workspace layout, skill sources, security sandbox, heartbeat |
-| 🔌 [Providers & Models](docs/providers.md) | 20+ LLM providers, model routing, model_list configuration, provider architecture |
-| 🔄 [Spawn & Async Tasks](docs/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration |
-| 🐛 [Troubleshooting](docs/troubleshooting.md) | Common issues and solutions |
-| 🔧 [Tools Configuration](docs/tools_configuration.md) | Per-tool enable/disable, exec policies |
+```bash
+# 1. Clone this repo
+git clone https://github.com/sipeed/picoclaw.git
+cd picoclaw
+
+# 2. First run — auto-generates docker/data/config.json then exits
+docker compose -f docker/docker-compose.yml --profile gateway up
+# The container prints "First-run setup complete." and stops.
+
+# 3. Set your API keys
+vim docker/data/config.json # Set provider API keys, bot tokens, etc.
+
+# 4. Start
+docker compose -f docker/docker-compose.yml --profile gateway up -d
+```
+
+> [!TIP]
+> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
+
+```bash
+# 5. Check logs
+docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
+
+# 6. Stop
+docker compose -f docker/docker-compose.yml --profile gateway down
+```
+
+### Launcher Mode (Web Console)
+
+The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat.
+
+```bash
+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.
+
+### Agent Mode (One-shot)
+
+```bash
+# Ask a question
+docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
+
+# Interactive mode
+docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
+```
+
+### Update
+
+```bash
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile gateway up -d
+```
+
+### 🚀 Quick Start
+
+> [!TIP]
+> Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month).
+
+**1. Initialize**
+
+```bash
+picoclaw onboard
+```
+
+**2. Configure** (`~/.picoclaw/config.json`)
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model_name": "gpt-5.4",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "ark-code-latest",
+ "model": "volcengine/ark-code-latest",
+ "api_key": "sk-your-api-key"
+ },
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_key": "your-api-key",
+ "request_timeout": 300
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "your-anthropic-key"
+ }
+ ],
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "tavily": {
+ "enabled": false,
+ "api_key": "YOUR_TAVILY_API_KEY",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ },
+ "perplexity": {
+ "enabled": false,
+ "api_key": "YOUR_PERPLEXITY_API_KEY",
+ "max_results": 5
+ },
+ "searxng": {
+ "enabled": false,
+ "base_url": "http://your-searxng-instance:8888",
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
+> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s).
+
+**3. Get API Keys**
+
+* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
+* **Web Search** (optional):
+ * [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month)
+ * [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface
+ * [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed)
+ * [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month)
+ * DuckDuckGo - Built-in fallback (no API key required)
+
+> **Note**: See `config.example.json` for a complete configuration template.
+
+**4. Chat**
+
+```bash
+picoclaw agent -m "What is 2+2?"
+```
+
+That's it! You have a working AI assistant in 2 minutes.
+
+---
+
+## 💬 Chat Apps
+
+Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom
+
+> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
+
+| Channel | Setup |
+| ------------ | ---------------------------------- |
+| **Telegram** | Easy (just a token) |
+| **Discord** | Easy (bot token + intents) |
+| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
+| **Matrix** | Medium (homeserver + bot access token) |
+| **QQ** | Easy (AppID + AppSecret) |
+| **DingTalk** | Medium (app credentials) |
+| **LINE** | Medium (credentials + webhook URL) |
+| **WeCom AI Bot** | Medium (Token + AES key) |
+
+
+Telegram (Recommended)
+
+**1. Create a bot**
+
+* Open Telegram, search `@BotFather`
+* Send `/newbot`, follow prompts
+* Copy the token
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+> Get your user ID from `@userinfobot` on Telegram.
+
+**3. Run**
+
+```bash
+picoclaw gateway
+```
+
+**4. Telegram command menu (auto-registered at startup)**
+
+PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync.
+Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor.
+
+If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
+
+
+
+
+Discord
+
+**1. Create a bot**
+
+* Go to
+* Create an application → Bot → Add Bot
+* Copy the bot token
+
+**2. Enable intents**
+
+* In the Bot settings, enable **MESSAGE CONTENT INTENT**
+* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
+
+**3. Get your User ID**
+* Discord Settings → Advanced → enable **Developer Mode**
+* Right-click your avatar → **Copy User ID**
+
+**4. Configure**
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+**5. Invite the bot**
+
+* OAuth2 → URL Generator
+* Scopes: `bot`
+* Bot Permissions: `Send Messages`, `Read Message History`
+* Open the generated invite URL and add the bot to your server
+
+**Optional: Group trigger mode**
+
+By default the bot responds to all messages in a server channel. To restrict responses to @-mentions only, add:
+
+```json
+{
+ "channels": {
+ "discord": {
+ "group_trigger": { "mention_only": true }
+ }
+ }
+}
+```
+
+You can also trigger by keyword prefixes (e.g. `!bot`):
+
+```json
+{
+ "channels": {
+ "discord": {
+ "group_trigger": { "prefixes": ["!bot"] }
+ }
+ }
+}
+```
+
+**6. Run**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+WhatsApp (native via whatsmeow)
+
+PicoClaw can connect to WhatsApp in two ways:
+
+- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`).
+- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false.
+
+**Configure (native)**
+
+```json
+{
+ "channels": {
+ "whatsapp": {
+ "enabled": true,
+ "use_native": true,
+ "session_store_path": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+If `session_store_path` is empty, the session is stored in `<workspace>/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices.
+
+
+
+
+QQ
+
+**1. Create a bot**
+
+- Go to [QQ Open Platform](https://q.qq.com/#)
+- Create an application → Get **AppID** and **AppSecret**
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access.
+
+**3. Run**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+DingTalk
+
+**1. Create a bot**
+
+* Go to [Open Platform](https://open.dingtalk.com/)
+* Create an internal app
+* Copy Client ID and Client Secret
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access.
+
+**3. Run**
+
+```bash
+picoclaw gateway
+```
+
+
+
+Matrix
+
+**1. Prepare bot account**
+
+* Use your preferred homeserver (e.g. `https://matrix.org` or self-hosted)
+* Create a bot user and obtain its access token
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "matrix": {
+ "enabled": true,
+ "homeserver": "https://matrix.org",
+ "user_id": "@your-bot:matrix.org",
+ "access_token": "YOUR_MATRIX_ACCESS_TOKEN",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**3. Run**
+
+```bash
+picoclaw gateway
+```
+
+For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](docs/channels/matrix/README.md).
+
+
+
+
+LINE
+
+**1. Create a LINE Official Account**
+
+- Go to [LINE Developers Console](https://developers.line.biz/)
+- Create a provider → Create a Messaging API channel
+- Copy **Channel Secret** and **Channel Access Token**
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "YOUR_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> LINE webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).
+
+**3. Set up Webhook URL**
+
+LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel:
+
+```bash
+# Example with ngrok (gateway default port is 18790)
+ngrok http 18790
+```
+
+Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**.
+
+**4. Run**
+
+```bash
+picoclaw gateway
+```
+
+> In group chats, the bot responds only when @mentioned. Replies quote the original message.
+
+
+
+
+WeCom (企业微信)
+
+PicoClaw supports three types of WeCom integration:
+
+**Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats
+**Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only
+**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat
+
+See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions.
+
+**Quick Setup - WeCom AI Bot:**
+
+**1. Create an AI Bot**
+
+* Go to WeCom Admin Console → AI Bot
+* Create a new AI Bot → Set name, avatar, etc.
+* Copy **Bot ID** and **Secret**
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "wecom_aibot": {
+ "enabled": true,
+ "bot_id": "YOUR_BOT_ID",
+ "secret": "YOUR_SECRET",
+ "allow_from": [],
+ "welcome_message": "Hello! How can I help you?"
+ }
+ }
+}
+```
+
+**3. Run**
+
+```bash
+picoclaw gateway
+```
+
+> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.
+
+
##
Join the Agent Social Network
diff --git a/config/config.example.json b/config/config.example.json
index 6df0a6293..221e89491 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -204,6 +204,8 @@
"wecom_aibot": {
"_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.",
"enabled": false,
+ "bot_id": "YOUR_BOT_ID",
+ "secret": "YOUR_SECRET",
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
diff --git a/docs/channels/wecom/wecom_aibot/README.zh.md b/docs/channels/wecom/wecom_aibot/README.zh.md
index d210528af..de4fba445 100644
--- a/docs/channels/wecom/wecom_aibot/README.zh.md
+++ b/docs/channels/wecom/wecom_aibot/README.zh.md
@@ -1,6 +1,6 @@
# 企业微信智能机器人 (AI Bot)
-企业微信智能机器人(AI Bot)是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议,并支持超时后通过 `response_url` 主动推送最终回复。
+企业微信智能机器人(AI Bot)是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议。
## 与其他 WeCom 通道的对比
@@ -19,9 +19,8 @@
"channels": {
"wecom_aibot": {
"enabled": true,
- "token": "YOUR_TOKEN",
- "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
- "webhook_path": "/webhook/wecom-aibot",
+ "bot_id": "YOUR_BOT_ID",
+ "secret": "YOUR_SECRET",
"allow_from": [],
"welcome_message": "你好!有什么可以帮助你的吗?",
"max_steps": 10
@@ -32,9 +31,8 @@
| 字段 | 类型 | 必填 | 描述 |
| ---------------- | ------ | ---- | -------------------------------------------------- |
-| token | string | 是 | 回调验证令牌,在 AI Bot 管理页面配置 |
-| encoding_aes_key | string | 是 | 43 字符 AES 密钥,在 AI Bot 管理页面随机生成 |
-| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-aibot) |
+| bot_id | string | 是 | AI Bot 的唯一标识,在 AI Bot 管理页面配置 |
+| secret | string | 是 | AI Bot 的密钥,在 AI Bot 管理页面配置 |
| allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 |
| welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 |
| reply_timeout | int | 否 | 回复超时时间(秒,默认:5) |
@@ -44,42 +42,8 @@
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)
2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot
-3. 在 AI Bot 配置页面,填写"消息接收"信息:
- - **URL**:`http://:18791/webhook/wecom-aibot`
- - **Token**:随机生成或自定义
- - **EncodingAESKey**:点击"随机生成",得到 43 字符密钥
-4. 将 Token 和 EncodingAESKey 填入 PicoClaw 配置文件,启动服务后回到管理后台保存(企业微信会发送验证请求)
-
-> [!TIP]
-> 服务器需要能被企业微信服务器访问。如在内网/本地开发,可使用 [ngrok](https://ngrok.com) 或 frp 做内网穿透。
-
-## 流式响应协议
-
-WeCom AI Bot 使用"流式拉取"协议,区别于普通 Webhook 的一次性回复:
-
-```
-用户发消息
- │
- ▼
-PicoClaw 立即返回 {finish: false}(Agent 开始处理)
- │
- ▼
-企业微信每隔约 1 秒拉取一次 {msgtype: "stream", stream: {id: "..."}}
- │
- ├─ Agent 未完成 → 返回 {finish: false}(继续等待)
- │
- └─ Agent 完成 → 返回 {finish: true, content: "回答内容"}
-```
-
-**超时处理**(任务超过 30 秒):
-
-若 Agent 处理时间超过约 30 秒(企业微信最大轮询窗口为 6 分钟),PicoClaw 会:
-
-1. 立即关闭流,向用户显示「⏳ 正在处理中,请稍候,结果将稍后发送。」
-2. Agent 继续在后台运行
-3. Agent 完成后,通过消息中携带的 `response_url` 将最终回复主动推送给用户
-
-> `response_url` 由企业微信颁发,有效期 1 小时,只可使用一次,无需加密,直接 POST markdown 消息体即可。
+3. 在 AI Bot 配置页面,配置Bot的名称、头像等信息,获取 `Bot ID` 和 `Secret`
+4. 在 PicoClaw 配置文件中添加上述配置,重启 PicoClaw
## 欢迎语
@@ -91,26 +55,12 @@ PicoClaw 立即返回 {finish: false}(Agent 开始处理)
## 常见问题
-### 回调 URL 验证失败
-
-- 确认服务器防火墙已开放对应端口(默认 18791)
-- 确认 `token` 与 `encoding_aes_key` 填写正确
-- 检查 PicoClaw 日志是否收到了来自企业微信的 GET 请求
-
### 消息没有回复
- 检查 `allow_from` 是否意外限制了发送者
- 查看日志中是否出现 `context canceled` 或 Agent 错误
- 确认 Agent 配置(`model_name` 等)正确
-### 超长任务没有收到最终推送
-
-- 确认消息回调中携带了 `response_url`(仅企业微信新版 AI Bot 支持)
-- 确认服务器能主动访问外网(需向 `response_url` POST 请求)
-- 查看日志关键词 `response_url mode` 和 `Sending reply via response_url`
-
## 参考文档
-- [企业微信 AI Bot 接入文档](https://developer.work.weixin.qq.com/document/path/100719)
-- [流式响应协议说明](https://developer.work.weixin.qq.com/document/path/100719)
-- [response_url 主动回复](https://developer.work.weixin.qq.com/document/path/101138)
+- [企业微信 AI Bot 接入文档](https://developer.work.weixin.qq.com/document/path/101463)
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 9e5fea1b6..2e1e12ded 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -296,7 +296,9 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
m.initChannel("wecom", "WeCom")
}
- if channels.WeComAIBot.Enabled && channels.WeComAIBot.Token != "" {
+ if m.config.Channels.WeComAIBot.Enabled &&
+ ((m.config.Channels.WeComAIBot.BotID != "" && m.config.Channels.WeComAIBot.Secret != "") ||
+ m.config.Channels.WeComAIBot.Token != "") {
m.initChannel("wecom_aibot", "WeCom AI Bot")
}
diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go
index 93fe8c36d..999f4f13b 100644
--- a/pkg/channels/wecom/aibot.go
+++ b/pkg/channels/wecom/aibot.go
@@ -22,6 +22,10 @@ import (
"github.com/sipeed/picoclaw/pkg/utils"
)
+// responseURLHTTPClient is a shared HTTP client for posting to WeCom response_url.
+// Reusing it enables connection pooling across replies.
+var responseURLHTTPClient = &http.Client{Timeout: 15 * time.Second}
+
// WeComAIBotChannel implements the Channel interface for WeCom AI Bot (企业微信智能机器人)
type WeComAIBotChannel struct {
*channels.BaseChannel
@@ -134,13 +138,25 @@ type WeComAIBotEncryptedResponse struct {
Nonce string `json:"nonce"`
}
-// NewWeComAIBotChannel creates a new WeCom AI Bot channel instance
+// NewWeComAIBotChannel creates a WeCom AI Bot channel instance.
+// If cfg.BotID and cfg.Secret are both set, it returns a WeComAIBotWSChannel
+// using the WebSocket long-connection API.
+// Otherwise it returns the webhook-mode WeComAIBotChannel (requires Token +
+// EncodingAESKey).
func NewWeComAIBotChannel(
cfg config.WeComAIBotConfig,
messageBus *bus.MessageBus,
-) (*WeComAIBotChannel, error) {
+) (channels.Channel, error) {
+ // WebSocket long-connection mode takes priority when BotID + Secret are set.
+ if cfg.BotID != "" && cfg.Secret != "" {
+ logger.InfoC("wecom_aibot", "BotID and Secret provided, using WebSocket mode")
+ return newWeComAIBotWSChannel(cfg, messageBus)
+ }
+ // Webhook (short-connection) mode.
if cfg.Token == "" || cfg.EncodingAESKey == "" {
- return nil, fmt.Errorf("token and encoding_aes_key are required for WeCom AI Bot")
+ return nil, fmt.Errorf(
+ "WeCom AI Bot requires either (bot_id + secret) for WebSocket mode " +
+ "or (token + encoding_aes_key) for webhook mode")
}
base := channels.NewBaseChannel("wecom_aibot", cfg, messageBus, cfg.AllowFrom,
@@ -782,8 +798,7 @@ func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) erro
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
- client := &http.Client{Timeout: 15 * time.Second}
- resp, err := client.Do(req)
+ resp, err := responseURLHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("post to response_url failed: %w: %w", channels.ErrTemporary, err)
}
@@ -793,7 +808,8 @@ func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) erro
return nil
}
- respBody, err := io.ReadAll(resp.Body)
+ const maxErrBody = 64 << 10 // 64 KB is more than enough for any error response
+ respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxErrBody))
if err != nil {
return fmt.Errorf("reading response_url body: %w: %w", channels.ErrTemporary, err)
}
@@ -895,17 +911,80 @@ func (c *WeComAIBotChannel) encryptMessage(plaintext, receiveid string) (string,
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
-// generateStreamID generates a random stream ID
-func (c *WeComAIBotChannel) generateStreamID() string {
+// func (c *WeComAIBotChannel) downloadAndDecryptImage(
+// ctx context.Context,
+// imageURL string,
+// ) ([]byte, error) {
+// // Download image
+// req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
+// if err != nil {
+// return nil, fmt.Errorf("failed to create request: %w", err)
+// }
+
+// client := &http.Client{
+// Timeout: 15 * time.Second,
+// }
+
+// resp, err := client.Do(req)
+// if err != nil {
+// return nil, fmt.Errorf("failed to download image: %w", err)
+// }
+// defer resp.Body.Close()
+
+// if resp.StatusCode != http.StatusOK {
+// return nil, fmt.Errorf("download failed with status: %d", resp.StatusCode)
+// }
+
+// // Limit image download to 20 MB to prevent memory exhaustion
+// const maxImageSize = 20 << 20 // 20 MB
+// encryptedData, err := io.ReadAll(io.LimitReader(resp.Body, maxImageSize+1))
+// if err != nil {
+// return nil, fmt.Errorf("failed to read image data: %w", err)
+// }
+// if len(encryptedData) > maxImageSize {
+// return nil, fmt.Errorf("image too large (exceeds %d MB)", maxImageSize>>20)
+// }
+
+// logger.DebugCF("wecom_aibot", "Image downloaded", map[string]any{
+// "size": len(encryptedData),
+// })
+
+// // Decode AES key
+// aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey)
+// if err != nil {
+// return nil, err
+// }
+
+// // Decrypt image (AES-CBC with IV = first 16 bytes of key, PKCS7 padding stripped)
+// decryptedData, err := decryptAESCBC(aesKey, encryptedData)
+// if err != nil {
+// return nil, fmt.Errorf("failed to decrypt image: %w", err)
+// }
+
+// logger.DebugCF("wecom_aibot", "Image decrypted", map[string]any{
+// "size": len(decryptedData),
+// })
+
+// return decryptedData, nil
+// }
+
+// generateRandomID generates a cryptographically random alphanumeric ID of
+// length n. Used for stream IDs and WebSocket request IDs.
+func generateRandomID(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
- b := make([]byte, 10)
+ b := make([]byte, n)
for i := range b {
- n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
- b[i] = letters[n.Int64()]
+ num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
+ b[i] = letters[num.Int64()]
}
return string(b)
}
+// generateStreamID generates a random 10-character stream ID (webhook mode).
+func (c *WeComAIBotChannel) generateStreamID() string {
+ return generateRandomID(10)
+}
+
// cleanupLoop periodically cleans up old streaming tasks
func (c *WeComAIBotChannel) cleanupLoop() {
ticker := time.NewTicker(5 * time.Minute)
diff --git a/pkg/channels/wecom/aibot_test.go b/pkg/channels/wecom/aibot_test.go
index 6f0664187..7c5ae67b1 100644
--- a/pkg/channels/wecom/aibot_test.go
+++ b/pkg/channels/wecom/aibot_test.go
@@ -3,12 +3,16 @@ package wecom
import (
"context"
"testing"
+ "time"
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
-func TestNewWeComAIBotChannel(t *testing.T) {
+// ---- Webhook mode tests ----
+
+func TestNewWeComAIBotChannel_WebhookMode(t *testing.T) {
t.Run("success with valid config", func(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
@@ -22,14 +26,16 @@ func TestNewWeComAIBotChannel(t *testing.T) {
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
-
if ch == nil {
t.Fatal("Expected channel to be created")
}
-
if ch.Name() != "wecom_aibot" {
t.Errorf("Expected name 'wecom_aibot', got '%s'", ch.Name())
}
+ // Webhook mode must implement WebhookHandler.
+ if _, ok := ch.(channels.WebhookHandler); !ok {
+ t.Error("Webhook mode channel should implement WebhookHandler")
+ }
})
t.Run("error with missing token", func(t *testing.T) {
@@ -37,10 +43,8 @@ func TestNewWeComAIBotChannel(t *testing.T) {
Enabled: true,
EncodingAESKey: "testkey1234567890123456789012345678901234567",
}
-
messageBus := bus.NewMessageBus()
_, err := NewWeComAIBotChannel(cfg, messageBus)
-
if err == nil {
t.Fatal("Expected error for missing token, got nil")
}
@@ -51,17 +55,15 @@ func TestNewWeComAIBotChannel(t *testing.T) {
Enabled: true,
Token: "test_token",
}
-
messageBus := bus.NewMessageBus()
_, err := NewWeComAIBotChannel(cfg, messageBus)
-
if err == nil {
t.Fatal("Expected error for missing encoding key, got nil")
}
})
}
-func TestWeComAIBotChannelStartStop(t *testing.T) {
+func TestWeComAIBotWebhookChannelStartStop(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
Token: "test_token",
@@ -76,22 +78,18 @@ func TestWeComAIBotChannelStartStop(t *testing.T) {
ctx := context.Background()
- // Test Start
if err := ch.Start(ctx); err != nil {
t.Fatalf("Failed to start channel: %v", err)
}
-
if !ch.IsRunning() {
- t.Error("Expected channel to be running")
+ t.Error("Expected channel to be running after Start")
}
- // Test Stop
if err := ch.Stop(ctx); err != nil {
t.Fatalf("Failed to stop channel: %v", err)
}
-
if ch.IsRunning() {
- t.Error("Expected channel to be stopped")
+ t.Error("Expected channel to be stopped after Stop")
}
}
@@ -102,13 +100,16 @@ func TestWeComAIBotChannelWebhookPath(t *testing.T) {
Token: "test_token",
EncodingAESKey: "testkey1234567890123456789012345678901234567",
}
-
messageBus := bus.NewMessageBus()
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
+ wh, ok := ch.(channels.WebhookHandler)
+ if !ok {
+ t.Fatal("Expected channel to implement WebhookHandler")
+ }
expectedPath := "/webhook/wecom-aibot"
- if ch.WebhookPath() != expectedPath {
- t.Errorf("Expected webhook path '%s', got '%s'", expectedPath, ch.WebhookPath())
+ if wh.WebhookPath() != expectedPath {
+ t.Errorf("Expected webhook path '%s', got '%s'", expectedPath, wh.WebhookPath())
}
})
@@ -120,12 +121,15 @@ func TestWeComAIBotChannelWebhookPath(t *testing.T) {
EncodingAESKey: "testkey1234567890123456789012345678901234567",
WebhookPath: customPath,
}
-
messageBus := bus.NewMessageBus()
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
- if ch.WebhookPath() != customPath {
- t.Errorf("Expected webhook path '%s', got '%s'", customPath, ch.WebhookPath())
+ wh, ok := ch.(channels.WebhookHandler)
+ if !ok {
+ t.Fatal("Expected channel to implement WebhookHandler")
+ }
+ if wh.WebhookPath() != customPath {
+ t.Errorf("Expected webhook path '%s', got '%s'", customPath, wh.WebhookPath())
}
})
}
@@ -136,19 +140,19 @@ func TestGenerateStreamID(t *testing.T) {
Token: "test_token",
EncodingAESKey: "testkey1234567890123456789012345678901234567",
}
-
messageBus := bus.NewMessageBus()
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
+ webhookCh, ok := ch.(*WeComAIBotChannel)
+ if !ok {
+ t.Fatal("Expected webhook mode channel")
+ }
- // Generate multiple IDs and check they are unique
ids := make(map[string]bool)
for i := 0; i < 100; i++ {
- id := ch.generateStreamID()
-
+ id := webhookCh.generateStreamID()
if len(id) != 10 {
t.Errorf("Expected stream ID length 10, got %d", len(id))
}
-
if ids[id] {
t.Errorf("Duplicate stream ID generated: %s", id)
}
@@ -157,35 +161,33 @@ func TestGenerateStreamID(t *testing.T) {
}
func TestEncryptDecrypt(t *testing.T) {
- // Use a valid 43-character base64 key (企业微信标准格式)
cfg := config.WeComAIBotConfig{
Enabled: true,
Token: "test_token",
EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", // 43 characters
}
-
messageBus := bus.NewMessageBus()
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
+ webhookCh, ok := ch.(*WeComAIBotChannel)
+ if !ok {
+ t.Fatal("Expected webhook mode channel")
+ }
plaintext := "Hello, World!"
receiveid := ""
- // Encrypt
- encrypted, err := ch.encryptMessage(plaintext, receiveid)
+ encrypted, err := webhookCh.encryptMessage(plaintext, receiveid)
if err != nil {
t.Fatalf("Failed to encrypt message: %v", err)
}
-
if encrypted == "" {
t.Fatal("Encrypted message is empty")
}
- // Decrypt
decrypted, err := decryptMessageWithVerify(encrypted, cfg.EncodingAESKey, receiveid)
if err != nil {
t.Fatalf("Failed to decrypt message: %v", err)
}
-
if decrypted != plaintext {
t.Errorf("Expected decrypted message '%s', got '%s'", plaintext, decrypted)
}
@@ -198,13 +200,256 @@ func TestGenerateSignature(t *testing.T) {
encrypt := "encrypted_msg"
signature := computeSignature(token, timestamp, nonce, encrypt)
-
if signature == "" {
t.Error("Generated signature is empty")
}
-
- // Verify signature using verifySignature function
if !verifySignature(token, signature, timestamp, nonce, encrypt) {
t.Error("Generated signature does not verify correctly")
}
}
+
+// ---- WebSocket long-connection mode tests ----
+
+func TestNewWeComAIBotChannel_WSMode(t *testing.T) {
+ t.Run("success with bot_id and secret", func(t *testing.T) {
+ cfg := config.WeComAIBotConfig{
+ Enabled: true,
+ BotID: "test_bot_id",
+ Secret: "test_secret",
+ }
+ messageBus := bus.NewMessageBus()
+ ch, err := NewWeComAIBotChannel(cfg, messageBus)
+ if err != nil {
+ t.Fatalf("Expected no error, got %v", err)
+ }
+ if ch == nil {
+ t.Fatal("Expected channel to be created")
+ }
+ if ch.Name() != "wecom_aibot" {
+ t.Errorf("Expected name 'wecom_aibot', got '%s'", ch.Name())
+ }
+ // WebSocket mode must NOT implement WebhookHandler.
+ if _, ok := ch.(channels.WebhookHandler); ok {
+ t.Error("WebSocket mode channel should NOT implement WebhookHandler")
+ }
+ })
+
+ t.Run("ws mode takes priority over webhook fields", func(t *testing.T) {
+ cfg := config.WeComAIBotConfig{
+ Enabled: true,
+ BotID: "test_bot_id",
+ Secret: "test_secret",
+ Token: "also_set",
+ EncodingAESKey: "testkey1234567890123456789012345678901234567",
+ }
+ messageBus := bus.NewMessageBus()
+ ch, err := NewWeComAIBotChannel(cfg, messageBus)
+ if err != nil {
+ t.Fatalf("Expected no error, got %v", err)
+ }
+ if _, ok := ch.(*WeComAIBotWSChannel); !ok {
+ t.Error("Expected WebSocket mode channel when both BotID+Secret and Token+Key are set")
+ }
+ })
+
+ t.Run("error with missing bot_id", func(t *testing.T) {
+ cfg := config.WeComAIBotConfig{
+ Enabled: true,
+ Secret: "test_secret",
+ }
+ messageBus := bus.NewMessageBus()
+ _, err := NewWeComAIBotChannel(cfg, messageBus)
+ // Missing bot_id alone means neither WS mode nor webhook mode is fully configured.
+ if err == nil {
+ t.Fatal("Expected error for missing bot_id, got nil")
+ }
+ })
+
+ t.Run("error with missing secret", func(t *testing.T) {
+ cfg := config.WeComAIBotConfig{
+ Enabled: true,
+ BotID: "test_bot_id",
+ }
+ messageBus := bus.NewMessageBus()
+ _, err := NewWeComAIBotChannel(cfg, messageBus)
+ if err == nil {
+ t.Fatal("Expected error for missing secret, got nil")
+ }
+ })
+}
+
+func TestWeComAIBotWSChannelStartStop(t *testing.T) {
+ cfg := config.WeComAIBotConfig{
+ Enabled: true,
+ BotID: "test_bot_id",
+ Secret: "test_secret",
+ }
+ messageBus := bus.NewMessageBus()
+ ch, err := NewWeComAIBotChannel(cfg, messageBus)
+ if err != nil {
+ t.Fatalf("Failed to create channel: %v", err)
+ }
+
+ ctx := context.Background()
+
+ // Start launches a background goroutine; it should not block or return an error.
+ if err := ch.Start(ctx); err != nil {
+ t.Fatalf("Failed to start channel: %v", err)
+ }
+ if !ch.IsRunning() {
+ t.Error("Expected channel to be running after Start")
+ }
+
+ // Stop should work regardless of whether the WebSocket actually connected.
+ if err := ch.Stop(ctx); err != nil {
+ t.Fatalf("Failed to stop channel: %v", err)
+ }
+ if ch.IsRunning() {
+ t.Error("Expected channel to be stopped after Stop")
+ }
+}
+
+func TestGenerateRandomID(t *testing.T) {
+ ids := make(map[string]bool)
+ for i := 0; i < 200; i++ {
+ id := generateRandomID(10)
+ if len(id) != 10 {
+ t.Errorf("Expected ID length 10, got %d", len(id))
+ }
+ if ids[id] {
+ t.Errorf("Duplicate ID generated: %s", id)
+ }
+ ids[id] = true
+ }
+}
+
+func TestWSGenerateID(t *testing.T) {
+ ids := make(map[string]bool)
+ for i := 0; i < 200; i++ {
+ id := wsGenerateID()
+ if len(id) != 10 {
+ t.Errorf("Expected ID length 10, got %d", len(id))
+ }
+ if ids[id] {
+ t.Errorf("Duplicate wsGenerateID result: %s", id)
+ }
+ ids[id] = true
+ }
+}
+
+// ---- Webhook streaming fallback tests ----
+
+// makeWebhookChannel creates a started WeComAIBotChannel for testing.
+func makeWebhookChannel(t *testing.T) *WeComAIBotChannel {
+ t.Helper()
+ cfg := config.WeComAIBotConfig{
+ Enabled: true,
+ Token: "test_token",
+ EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG",
+ }
+ ch, err := NewWeComAIBotChannel(cfg, bus.NewMessageBus())
+ if err != nil {
+ t.Fatalf("create channel: %v", err)
+ }
+ wc := ch.(*WeComAIBotChannel)
+ wc.ctx, wc.cancel = context.WithCancel(context.Background())
+ return wc
+}
+
+// makeStreamTask creates and registers a streamTask for testing.
+func makeStreamTask(t *testing.T, ch *WeComAIBotChannel, streamID, chatID string, deadline time.Time) *streamTask {
+ t.Helper()
+ task := &streamTask{
+ StreamID: streamID,
+ ChatID: chatID,
+ Deadline: deadline,
+ answerCh: make(chan string, 1),
+ }
+ task.ctx, task.cancel = context.WithCancel(ch.ctx)
+ ch.taskMu.Lock()
+ ch.streamTasks[streamID] = task
+ ch.chatTasks[chatID] = append(ch.chatTasks[chatID], task)
+ ch.taskMu.Unlock()
+ return task
+}
+
+// TestGetStreamResponse_ImmediateAnswer verifies that when the agent has already
+// placed its answer in answerCh, getStreamResponse returns a finish=true response
+// and fully removes the task.
+func TestGetStreamResponse_ImmediateAnswer(t *testing.T) {
+ ch := makeWebhookChannel(t)
+ defer ch.cancel()
+
+ task := makeStreamTask(t, ch, "stream-1", "chat-1", time.Now().Add(30*time.Second))
+ task.answerCh <- "hello from agent"
+
+ result := ch.getStreamResponse(task, "ts123", "nonce123")
+ if result == "" {
+ t.Fatal("expected non-empty encrypted response")
+ }
+
+ ch.taskMu.RLock()
+ _, exists := ch.streamTasks["stream-1"]
+ ch.taskMu.RUnlock()
+ if exists {
+ t.Error("task should have been removed from streamTasks after normal finish")
+ }
+ if !task.Finished {
+ t.Error("task.Finished should be true after normal finish")
+ }
+}
+
+// TestGetStreamResponse_DeadlinePassed verifies that when the stream deadline has
+// elapsed (no agent reply yet), getStreamResponse closes the stream but keeps the
+// task alive so the response_url fallback can still deliver the answer.
+func TestGetStreamResponse_DeadlinePassed(t *testing.T) {
+ ch := makeWebhookChannel(t)
+ defer ch.cancel()
+
+ task := makeStreamTask(t, ch, "stream-2", "chat-2", time.Now().Add(-time.Millisecond))
+
+ result := ch.getStreamResponse(task, "ts456", "nonce456")
+ if result == "" {
+ t.Fatal("expected non-empty encrypted response")
+ }
+
+ ch.taskMu.RLock()
+ _, stillStreaming := ch.streamTasks["stream-2"]
+ ch.taskMu.RUnlock()
+ if stillStreaming {
+ t.Error("task should have been removed from streamTasks after deadline")
+ }
+ if !task.StreamClosed {
+ t.Error("task.StreamClosed should be true after deadline")
+ }
+ if task.Finished {
+ t.Error("task.Finished must remain false: agent reply still expected via response_url")
+ }
+}
+
+// TestGetStreamResponse_StillPending verifies that when neither the agent has
+// replied nor the deadline has passed, getStreamResponse returns without altering
+// task state (client should poll again).
+func TestGetStreamResponse_StillPending(t *testing.T) {
+ ch := makeWebhookChannel(t)
+ defer ch.cancel()
+
+ task := makeStreamTask(t, ch, "stream-3", "chat-3", time.Now().Add(30*time.Second))
+
+ result := ch.getStreamResponse(task, "ts789", "nonce789")
+ if result == "" {
+ t.Fatal("expected non-empty encrypted response")
+ }
+
+ ch.taskMu.RLock()
+ _, exists := ch.streamTasks["stream-3"]
+ ch.taskMu.RUnlock()
+ if !exists {
+ t.Error("pending task should still be in streamTasks")
+ }
+ if task.Finished || task.StreamClosed {
+ t.Error("pending task should not be finished or stream-closed")
+ }
+ // Cleanup.
+ ch.removeTask(task)
+}
diff --git a/pkg/channels/wecom/aibot_ws.go b/pkg/channels/wecom/aibot_ws.go
new file mode 100644
index 000000000..830e763b9
--- /dev/null
+++ b/pkg/channels/wecom/aibot_ws.go
@@ -0,0 +1,1346 @@
+package wecom
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/identity"
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/media"
+ "github.com/sipeed/picoclaw/pkg/utils"
+)
+
+// Long-connection WebSocket endpoint.
+// Ref: https://developer.work.weixin.qq.com/document/path/101463
+const (
+ wsEndpoint = "wss://openws.work.weixin.qq.com"
+ wsHeartbeatInterval = 30 * time.Second
+ wsConnectTimeout = 15 * time.Second
+ wsSubscribeTimeout = 10 * time.Second
+ wsSendMsgTimeout = 10 * time.Second
+ wsRespondMsgTimeout = 10 * time.Second
+ wsWelcomeMsgTimeout = 5 * time.Second // WeCom requires welcome reply within 5 seconds
+ wsMaxReconnectWait = 60 * time.Second
+ wsInitialReconnect = time.Second
+
+ // WeCom requires finish=true within 6 minutes of the first stream frame.
+ // wsStreamTickInterval controls how often we send an in-progress hint.
+ // wsStreamMaxDuration is a safety margin below the 6-minute hard limit.
+ wsStreamTickInterval = 30 * time.Second
+ wsStreamMaxDuration = 5*time.Minute + 30*time.Second
+
+ // wsImageDownloadTimeout caps the time we spend downloading an inbound image.
+ wsImageDownloadTimeout = 30 * time.Second
+
+ // Keep req_id -> chat route for late fallback pushes after stream window closes.
+ wsLateReplyRouteTTL = 30 * time.Minute
+
+ // wsStreamMaxContentBytes is the maximum UTF-8 byte length for the content field
+ // of a single WeCom AI Bot stream / text / markdown frame.
+ // Ref: https://developer.work.weixin.qq.com/document/path/101463
+ wsStreamMaxContentBytes = 20480
+)
+
+// wsImageHTTPClient is a shared HTTP client for downloading inbound images.
+// Reusing it enables connection pooling across multiple image downloads.
+var wsImageHTTPClient = &http.Client{Timeout: wsImageDownloadTimeout}
+
+// WeComAIBotWSChannel implements channels.Channel for WeCom AI Bot using the
+// WebSocket long-connection API.
+// Unlike the webhook counterpart it does NOT implement WebhookHandler, so the
+// HTTP manager will not register any callback URL for it.
+type WeComAIBotWSChannel struct {
+ *channels.BaseChannel
+ config config.WeComAIBotConfig
+ ctx context.Context
+ cancel context.CancelFunc
+
+ // conn is the active WebSocket connection; nil when disconnected.
+ // All writes are serialized through connMu.
+ conn *websocket.Conn
+ connMu sync.Mutex
+
+ // dedupe prevents duplicate message processing (WeCom may re-deliver).
+ dedupe *MessageDeduplicator
+
+ // reqStates holds per-req_id runtime state.
+ // It unifies active task state and late-reply fallback routing.
+ reqStates map[string]*wsReqState
+ reqStatesMu sync.Mutex
+
+ // reqPending correlates command req_ids with response channels.
+ // Used only for subscribe/ping command-response pairs.
+ reqPending map[string]chan wsEnvelope
+ reqPendingMu sync.Mutex
+}
+
+// wsTask tracks one in-progress agent reply for a single chat turn.
+type wsTask struct {
+ ReqID string // req_id echoed in all replies for this turn
+ ChatID string
+ ChatType uint32
+ StreamID string // our generated stream.id
+ answerCh chan string // agent delivers its reply here via Send()
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+type wsReqState struct {
+ Task *wsTask
+ Route wsLateReplyRoute
+}
+
+type wsLateReplyRoute struct {
+ ChatID string
+ ChatType uint32
+ ReadyAt time.Time
+ ExpiresAt time.Time
+}
+
+// ---- WebSocket protocol types ----
+
+// wsEnvelope is the generic JSON envelope for all WebSocket messages.
+type wsEnvelope struct {
+ Cmd string `json:"cmd,omitempty"`
+ Headers wsHeaders `json:"headers"`
+ Body json.RawMessage `json:"body,omitempty"`
+ ErrCode int `json:"errcode,omitempty"`
+ ErrMsg string `json:"errmsg,omitempty"`
+}
+
+type wsHeaders struct {
+ ReqID string `json:"req_id"`
+}
+
+// wsCommand is an outgoing request sent over the WebSocket.
+type wsCommand struct {
+ Cmd string `json:"cmd"`
+ Headers wsHeaders `json:"headers"`
+ Body any `json:"body,omitempty"`
+}
+
+type wsSendMsgBody struct {
+ ChatID string `json:"chatid"`
+ ChatType uint32 `json:"chat_type,omitempty"`
+ MsgType string `json:"msgtype"`
+ Markdown *wsMarkdownContent `json:"markdown,omitempty"`
+}
+
+// wsRespondMsgBody is the body for aibot_respond_msg / aibot_respond_welcome_msg.
+type wsRespondMsgBody struct {
+ MsgType string `json:"msgtype"`
+ Stream *wsStreamContent `json:"stream,omitempty"`
+ Text *wsTextContent `json:"text,omitempty"`
+ Markdown *wsMarkdownContent `json:"markdown,omitempty"`
+ Image *wsImageContent `json:"image,omitempty"`
+}
+
+type wsStreamContent struct {
+ ID string `json:"id"`
+ Finish bool `json:"finish"`
+ Content string `json:"content,omitempty"`
+}
+
+// wsImageContent carries a base64-encoded image payload for outbound messages.
+type wsImageContent struct {
+ Base64 string `json:"base64"`
+ MD5 string `json:"md5"`
+}
+
+type wsTextContent struct {
+ Content string `json:"content"`
+}
+
+type wsMarkdownContent struct {
+ Content string `json:"content"`
+}
+
+// WeComAIBotWSMessage is the decoded body of aibot_msg_callback /
+// aibot_event_callback in WebSocket long-connection mode.
+// The structure mirrors WeComAIBotMessage but includes extra fields
+// that only appear in long-connection callbacks (Voice, AESKey on Image/File).
+type WeComAIBotWSMessage struct {
+ MsgID string `json:"msgid"`
+ CreateTime int64 `json:"create_time,omitempty"`
+ AIBotID string `json:"aibotid"`
+ ChatID string `json:"chatid,omitempty"`
+ ChatType string `json:"chattype,omitempty"` // "single" | "group"
+ From struct {
+ UserID string `json:"userid"`
+ } `json:"from"`
+ MsgType string `json:"msgtype"`
+ Text *struct {
+ Content string `json:"content"`
+ } `json:"text,omitempty"`
+ Image *struct {
+ URL string `json:"url"`
+ AESKey string `json:"aeskey,omitempty"` // long-connection: per-resource decrypt key
+ } `json:"image,omitempty"`
+ Voice *struct {
+ Content string `json:"content"` // WeCom transcribes voice to text in callbacks
+ } `json:"voice,omitempty"`
+ Mixed *struct {
+ MsgItem []struct {
+ MsgType string `json:"msgtype"`
+ Text *struct {
+ Content string `json:"content"`
+ } `json:"text,omitempty"`
+ Image *struct {
+ URL string `json:"url"`
+ AESKey string `json:"aeskey,omitempty"`
+ } `json:"image,omitempty"`
+ } `json:"msg_item"`
+ } `json:"mixed,omitempty"`
+ Event *struct {
+ EventType string `json:"eventtype"`
+ } `json:"event,omitempty"`
+ File *struct {
+ URL string `json:"url"`
+ AESKey string `json:"aeskey,omitempty"`
+ } `json:"file,omitempty"`
+ Video *struct {
+ URL string `json:"url"`
+ AESKey string `json:"aeskey,omitempty"`
+ } `json:"video,omitempty"`
+}
+
+// ---- Constructor ----
+
+// newWeComAIBotWSChannel creates a WeComAIBotWSChannel for WebSocket mode.
+func newWeComAIBotWSChannel(
+ cfg config.WeComAIBotConfig,
+ messageBus *bus.MessageBus,
+) (*WeComAIBotWSChannel, error) {
+ if cfg.BotID == "" || cfg.Secret == "" {
+ return nil, fmt.Errorf("bot_id and secret are required for WeCom AI Bot WebSocket mode")
+ }
+
+ base := channels.NewBaseChannel("wecom_aibot", cfg, messageBus, cfg.AllowFrom,
+ channels.WithReasoningChannelID(cfg.ReasoningChannelID),
+ )
+
+ return &WeComAIBotWSChannel{
+ BaseChannel: base,
+ config: cfg,
+ dedupe: NewMessageDeduplicator(wecomMaxProcessedMessages),
+ reqStates: make(map[string]*wsReqState),
+ reqPending: make(map[string]chan wsEnvelope),
+ }, nil
+}
+
+// ---- Channel interface ----
+
+// Name implements channels.Channel.
+func (c *WeComAIBotWSChannel) Name() string { return "wecom_aibot" }
+
+// Start connects to the WeCom WebSocket endpoint and begins message processing.
+func (c *WeComAIBotWSChannel) Start(ctx context.Context) error {
+ logger.InfoC("wecom_aibot", "Starting WeCom AI Bot channel (WebSocket long-connection mode)...")
+ c.ctx, c.cancel = context.WithCancel(ctx)
+ c.SetRunning(true)
+ go c.connectLoop()
+ logger.InfoC("wecom_aibot", "WeCom AI Bot channel started (WebSocket mode)")
+ return nil
+}
+
+// Stop shuts down the channel and closes the WebSocket connection.
+func (c *WeComAIBotWSChannel) Stop(_ context.Context) error {
+ logger.InfoC("wecom_aibot", "Stopping WeCom AI Bot channel (WebSocket mode)...")
+ if c.cancel != nil {
+ c.cancel()
+ }
+ c.connMu.Lock()
+ if c.conn != nil {
+ c.conn.Close()
+ c.conn = nil
+ }
+ c.connMu.Unlock()
+ c.SetRunning(false)
+ logger.InfoC("wecom_aibot", "WeCom AI Bot channel stopped")
+ return nil
+}
+
+// Send delivers the agent reply for msg.ChatID.
+// The waiting task goroutine picks it up and writes the final stream response.
+func (c *WeComAIBotWSChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+
+ // msg.ChatID carries the inbound req_id (set by dispatchWSAgentTask).
+ // For cron-triggered messages, msg.ChatID is the real WeCom chat/user ID
+ // and there will be no matching entry in reqStates; fall through to proactive push.
+ task, route, ok := c.getReqState(msg.ChatID)
+ if !ok {
+ // No req_id record found — this is a cron/scheduler-originated message.
+ // Send it as a proactive markdown push using the chat ID directly.
+ logger.InfoCF("wecom_aibot", "Send: no req_id state, delivering via proactive push (cron/scheduler)",
+ map[string]any{"chat_id": msg.ChatID})
+ if err := c.wsSendActivePush(msg.ChatID, 0, msg.Content); err != nil {
+ logger.WarnCF("wecom_aibot", "Proactive push failed",
+ map[string]any{"chat_id": msg.ChatID, "error": err.Error()})
+ return fmt.Errorf("websocket delivery failed: %w", channels.ErrSendFailed)
+ }
+ return nil
+ }
+
+ if task == nil {
+ if time.Now().Before(route.ReadyAt) {
+ // Keep using aibot_respond_msg within stream window; do not proactively
+ // push unless wsStreamMaxDuration has elapsed.
+ logger.WarnCF("wecom_aibot", "Send: stream window still open, skip proactive push",
+ map[string]any{"req_id": msg.ChatID, "ready_at": route.ReadyAt.Format(time.RFC3339)})
+ return nil
+ }
+
+ if err := c.wsSendActivePush(route.ChatID, route.ChatType, msg.Content); err != nil {
+ logger.WarnCF("wecom_aibot", "Late reply proactive push failed",
+ map[string]any{"req_id": msg.ChatID, "chat_id": route.ChatID, "error": err.Error()})
+ return fmt.Errorf("websocket delivery failed: %w", channels.ErrSendFailed)
+ }
+ logger.InfoCF("wecom_aibot", "Late reply delivered via proactive push",
+ map[string]any{"req_id": msg.ChatID, "chat_id": route.ChatID, "chat_type": route.ChatType})
+ c.deleteReqState(msg.ChatID)
+ return nil
+ }
+
+ // Non-blocking fast path: when answerCh has space, deliver without racing
+ // against task.ctx.Done() (which fires when the task is canceled by a new
+ // incoming message, but the response must still be sent).
+ select {
+ case task.answerCh <- msg.Content:
+ return nil
+ default:
+ }
+ // answerCh was full; block with cancellation guards.
+ select {
+ case task.answerCh <- msg.Content:
+ case <-task.ctx.Done():
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ return nil
+}
+
+// ---- Connection management ----
+
+// wsBackoffResetDuration is the minimum duration a WebSocket connection must
+// stay up before we reset the reconnect backoff to its initial value. This
+// prevents a short burst of failures from causing long waits after later,
+// stable connection periods.
+const wsBackoffResetDuration = time.Minute
+
+// connectLoop maintains the WebSocket connection, reconnecting on failure with
+// exponential backoff.
+func (c *WeComAIBotWSChannel) connectLoop() {
+ backoff := wsInitialReconnect
+ for {
+ select {
+ case <-c.ctx.Done():
+ return
+ default:
+ }
+
+ logger.InfoC("wecom_aibot", "Connecting to WeCom WebSocket endpoint...")
+ start := time.Now()
+ if err := c.runConnection(); err != nil {
+ elapsed := time.Since(start)
+ // If the connection was stable for long enough, reset backoff so that
+ // a previous burst of failures does not keep us at the maximum delay.
+ if elapsed >= wsBackoffResetDuration {
+ backoff = wsInitialReconnect
+ }
+ select {
+ case <-c.ctx.Done():
+ return
+ default:
+ logger.WarnCF("wecom_aibot", "WebSocket connection lost, reconnecting",
+ map[string]any{"error": err.Error(), "backoff": backoff.String()})
+ select {
+ case <-time.After(backoff):
+ case <-c.ctx.Done():
+ return
+ }
+ if backoff < wsMaxReconnectWait {
+ backoff *= 2
+ if backoff > wsMaxReconnectWait {
+ backoff = wsMaxReconnectWait
+ }
+ }
+ }
+ } else {
+ // Clean exit (context canceled); stop reconnecting.
+ return
+ }
+ }
+}
+
+// runConnection dials, subscribes, and runs the read/heartbeat loops until the
+// connection closes or the channel context is canceled.
+func (c *WeComAIBotWSChannel) runConnection() error {
+ dialCtx, dialCancel := context.WithTimeout(c.ctx, wsConnectTimeout)
+ conn, httpResp, err := websocket.DefaultDialer.DialContext(dialCtx, wsEndpoint, nil)
+ dialCancel()
+ if httpResp != nil {
+ httpResp.Body.Close()
+ }
+ if err != nil {
+ return fmt.Errorf("dial failed: %w", err)
+ }
+
+ c.connMu.Lock()
+ c.conn = conn
+ c.connMu.Unlock()
+
+ defer func() {
+ c.connMu.Lock()
+ if c.conn == conn {
+ c.conn = nil
+ }
+ c.connMu.Unlock()
+ // Cancel any tasks that were started over this connection so their
+ // agent goroutines do not keep running after the connection is gone.
+ c.cancelAllTasks()
+ }()
+
+ // ---- Read loop (must start BEFORE subscribing) ----
+ // sendAndWait blocks waiting for the subscribe response on reqPending;
+ // readLoop is the only goroutine that delivers messages to reqPending.
+ // Starting readLoop first avoids a deadlock where sendAndWait times out
+ // because no one reads the server's reply.
+ readErrCh := make(chan error, 1)
+ go func() { readErrCh <- c.readLoop(conn) }()
+
+ // ---- Subscribe ----
+ reqID := wsGenerateID()
+ resp, err := c.sendAndWait(conn, reqID, wsCommand{
+ Cmd: "aibot_subscribe",
+ Headers: wsHeaders{ReqID: reqID},
+ Body: map[string]string{
+ "bot_id": c.config.BotID,
+ "secret": c.config.Secret,
+ },
+ }, wsSubscribeTimeout)
+ if err != nil {
+ conn.Close() // stop readLoop
+ <-readErrCh
+ return fmt.Errorf("subscribe failed: %w", err)
+ }
+ if resp.ErrCode != 0 {
+ conn.Close()
+ <-readErrCh
+ return fmt.Errorf("subscribe rejected (errcode=%d): %s", resp.ErrCode, resp.ErrMsg)
+ }
+
+ logger.InfoC("wecom_aibot", "WebSocket subscription successful")
+
+ // ---- Heartbeat goroutine ----
+ hbDone := make(chan struct{})
+ go func() {
+ defer close(hbDone)
+ c.heartbeatLoop(conn)
+ }()
+
+ // Wait for the read loop to exit, then tear down the heartbeat.
+ readErr := <-readErrCh
+ conn.Close() // signal heartbeat to stop (idempotent)
+ <-hbDone
+ return readErr
+}
+
+// sendAndWait registers a pending-response slot, sends cmd, and blocks until
+// the matching response arrives or the timeout/context fires.
+func (c *WeComAIBotWSChannel) sendAndWait(
+ conn *websocket.Conn,
+ reqID string,
+ cmd wsCommand,
+ timeout time.Duration,
+) (wsEnvelope, error) {
+ ch := make(chan wsEnvelope, 1)
+ c.reqPendingMu.Lock()
+ c.reqPending[reqID] = ch
+ c.reqPendingMu.Unlock()
+
+ cleanup := func() {
+ c.reqPendingMu.Lock()
+ delete(c.reqPending, reqID)
+ c.reqPendingMu.Unlock()
+ }
+
+ data, err := json.Marshal(cmd)
+ if err != nil {
+ cleanup()
+ return wsEnvelope{}, fmt.Errorf("marshal command: %w", err)
+ }
+ c.connMu.Lock()
+ err = conn.WriteMessage(websocket.TextMessage, data)
+ c.connMu.Unlock()
+ if err != nil {
+ cleanup()
+ return wsEnvelope{}, fmt.Errorf("write command: %w", err)
+ }
+
+ timer := time.NewTimer(timeout)
+ defer timer.Stop()
+ select {
+ case env := <-ch:
+ return env, nil
+ case <-timer.C:
+ cleanup()
+ return wsEnvelope{}, fmt.Errorf("timeout waiting for response (req_id=%s)", reqID)
+ case <-c.ctx.Done():
+ cleanup()
+ return wsEnvelope{}, c.ctx.Err()
+ }
+}
+
+// heartbeatLoop sends a ping every wsHeartbeatInterval until conn is closed.
+// It validates the server's pong response via sendAndWait; a failed pong
+// triggers a reconnection by closing the connection.
+func (c *WeComAIBotWSChannel) heartbeatLoop(conn *websocket.Conn) {
+ ticker := time.NewTicker(wsHeartbeatInterval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ reqID := wsGenerateID()
+ resp, err := c.sendAndWait(conn, reqID, wsCommand{
+ Cmd: "ping",
+ Headers: wsHeaders{ReqID: reqID},
+ }, wsHeartbeatInterval)
+ if err != nil {
+ logger.WarnCF("wecom_aibot", "Heartbeat failed, closing connection",
+ map[string]any{"error": err.Error()})
+ conn.Close()
+ return
+ }
+ if resp.ErrCode != 0 {
+ logger.WarnCF("wecom_aibot", "Heartbeat rejected",
+ map[string]any{"errcode": resp.ErrCode, "errmsg": resp.ErrMsg})
+ conn.Close()
+ return
+ }
+ logger.DebugCF("wecom_aibot", "Heartbeat pong received", map[string]any{"req_id": reqID})
+ case <-c.ctx.Done():
+ return
+ }
+ }
+}
+
+// readLoop reads WebSocket messages and dispatches them until the connection
+// closes or the channel is stopped.
+func (c *WeComAIBotWSChannel) readLoop(conn *websocket.Conn) error {
+ for {
+ _, raw, err := conn.ReadMessage()
+ if err != nil {
+ select {
+ case <-c.ctx.Done():
+ return nil // clean shutdown
+ default:
+ return fmt.Errorf("read error: %w", err)
+ }
+ }
+
+ var env wsEnvelope
+ if err := json.Unmarshal(raw, &env); err != nil {
+ logger.WarnCF("wecom_aibot", "Failed to parse WebSocket message",
+ map[string]any{"error": err.Error(), "raw": string(raw)})
+ continue
+ }
+
+ // Command responses have an empty Cmd field; forward to any waiting
+ // sendAndWait() call, or silently drop if no one is waiting (e.g.
+ // late responses after timeout).
+ if env.Cmd == "" && env.Headers.ReqID != "" {
+ c.reqPendingMu.Lock()
+ ch, ok := c.reqPending[env.Headers.ReqID]
+ if ok {
+ delete(c.reqPending, env.Headers.ReqID)
+ }
+ c.reqPendingMu.Unlock()
+ if ok {
+ ch <- env
+ }
+ continue
+ }
+
+ // Dispatch to appropriate handler in a separate goroutine so the
+ // read loop is never blocked by a slow agent.
+ go c.handleEnvelope(env)
+ }
+}
+
+// ---- Message / event handlers ----
+
+// handleEnvelope routes a WebSocket envelope to the right handler.
+func (c *WeComAIBotWSChannel) handleEnvelope(env wsEnvelope) {
+ switch env.Cmd {
+ case "aibot_msg_callback":
+ c.handleMsgCallback(env)
+ case "aibot_event_callback":
+ c.handleEventCallback(env)
+ default:
+ logger.DebugCF("wecom_aibot", "Unhandled WebSocket command",
+ map[string]any{"cmd": env.Cmd})
+ }
+}
+
+// handleMsgCallback processes aibot_msg_callback.
+func (c *WeComAIBotWSChannel) handleMsgCallback(env wsEnvelope) {
+ var msg WeComAIBotWSMessage
+ if err := json.Unmarshal(env.Body, &msg); err != nil {
+ logger.WarnCF("wecom_aibot", "Failed to parse msg callback body",
+ map[string]any{"error": err.Error()})
+ return
+ }
+
+ // Deduplicate by msgid (WeCom may re-deliver on network issues).
+ if msg.MsgID != "" && !c.dedupe.MarkMessageProcessed(msg.MsgID) {
+ logger.DebugCF("wecom_aibot", "Duplicate message ignored",
+ map[string]any{"msgid": msg.MsgID})
+ return
+ }
+
+ reqID := env.Headers.ReqID
+ switch msg.MsgType {
+ case "text":
+ c.handleWSTextMessage(reqID, msg)
+ case "image":
+ c.handleWSImageMessage(reqID, msg)
+ case "voice":
+ c.handleWSVoiceMessage(reqID, msg)
+ case "mixed":
+ c.handleWSMixedMessage(reqID, msg)
+ case "file":
+ c.handleWSFileMessage(reqID, msg)
+ case "video":
+ c.handleWSVideoMessage(reqID, msg)
+ default:
+ logger.WarnCF("wecom_aibot", "Unsupported message type",
+ map[string]any{"msgtype": msg.MsgType})
+ c.wsSendStreamFinish(reqID, wsGenerateID(),
+ "Unsupported message type: "+msg.MsgType)
+ }
+}
+
+// handleEventCallback processes aibot_event_callback.
+func (c *WeComAIBotWSChannel) handleEventCallback(env wsEnvelope) {
+ var msg WeComAIBotWSMessage
+ if err := json.Unmarshal(env.Body, &msg); err != nil {
+ logger.WarnCF("wecom_aibot", "Failed to parse event callback body",
+ map[string]any{"error": err.Error()})
+ return
+ }
+
+ // Deduplicate by msgid.
+ if msg.MsgID != "" && !c.dedupe.MarkMessageProcessed(msg.MsgID) {
+ logger.DebugCF("wecom_aibot", "Duplicate event ignored",
+ map[string]any{"msgid": msg.MsgID})
+ return
+ }
+
+ var eventType string
+ if msg.Event != nil {
+ eventType = msg.Event.EventType
+ }
+ logger.DebugCF("wecom_aibot", "Received event callback",
+ map[string]any{"event_type": eventType})
+
+ switch eventType {
+ case "enter_chat":
+ if c.config.WelcomeMessage != "" {
+ c.wsSendWelcomeMsg(env.Headers.ReqID, c.config.WelcomeMessage)
+ }
+ case "disconnected_event":
+ // The server will close this connection after sending this event.
+ // connectLoop will detect the closure and reconnect automatically.
+ logger.WarnC("wecom_aibot",
+ "Received disconnected_event: this connection is being replaced by a newer one")
+ default:
+ logger.DebugCF("wecom_aibot", "Unhandled event type",
+ map[string]any{"event_type": eventType})
+ }
+}
+
+// handleWSTextMessage dispatches a plain-text message to the agent and streams
+// the reply back over the WebSocket connection.
+func (c *WeComAIBotWSChannel) handleWSTextMessage(reqID string, msg WeComAIBotWSMessage) {
+ if msg.Text == nil {
+ logger.ErrorC("wecom_aibot", "text message missing text field")
+ return
+ }
+ c.dispatchWSAgentTask(reqID, msg, msg.Text.Content, nil)
+}
+
+// handleWSImageMessage downloads and stores the inbound image, then dispatches
+// it to the agent as a media-tagged message.
+func (c *WeComAIBotWSChannel) handleWSImageMessage(reqID string, msg WeComAIBotWSMessage) {
+ if msg.Image == nil {
+ logger.WarnC("wecom_aibot", "Image message missing image field")
+ c.wsSendStreamFinish(reqID, wsGenerateID(), "Image message could not be processed.")
+ return
+ }
+ c.wsHandleMediaMessage(reqID, msg, msg.Image.URL, msg.Image.AESKey, "image")
+}
+
+// wsHandleMediaMessage is a shared helper for image, file and video messages.
+// It downloads the resource, stores it in MediaStore, and dispatches to the agent.
+func (c *WeComAIBotWSChannel) wsHandleMediaMessage(
+ reqID string, msg WeComAIBotWSMessage,
+ resourceURL, aesKey, label string,
+) {
+ chatID := wsChatID(msg)
+
+ ctx, cancel := context.WithTimeout(c.ctx, wsImageDownloadTimeout)
+ defer cancel()
+
+ ref, err := c.storeWSMedia(ctx, chatID, msg.MsgID, resourceURL, aesKey, wsLabelToDefaultExt(label))
+ if err != nil {
+ logger.WarnCF("wecom_aibot", "Failed to download/store WS "+label,
+ map[string]any{"error": err.Error(), "url": resourceURL})
+ c.wsSendStreamFinish(reqID, wsGenerateID(),
+ strings.ToUpper(label[:1])+label[1:]+" message could not be processed.")
+ return
+ }
+
+ c.dispatchWSAgentTask(reqID, msg, "["+label+"]", []string{ref})
+}
+
+// handleWSMixedMessage handles mixed text+image messages.
+// All text parts are collected into the content string; all image parts are
+// downloaded and stored in MediaStore before dispatching to the agent.
+func (c *WeComAIBotWSChannel) handleWSMixedMessage(reqID string, msg WeComAIBotWSMessage) {
+ if msg.Mixed == nil {
+ logger.WarnC("wecom_aibot", "Mixed message has no content")
+ c.wsSendStreamFinish(reqID, wsGenerateID(), "Mixed message type is not yet fully supported.")
+ return
+ }
+
+ chatID := wsChatID(msg)
+
+ ctx, cancel := context.WithTimeout(c.ctx, wsImageDownloadTimeout)
+ defer cancel()
+
+ var textParts []string
+ var mediaRefs []string
+ for _, item := range msg.Mixed.MsgItem {
+ switch item.MsgType {
+ case "text":
+ if item.Text != nil && item.Text.Content != "" {
+ textParts = append(textParts, item.Text.Content)
+ }
+ case "image":
+ if item.Image != nil {
+ ref, err := c.storeWSMedia(ctx, chatID,
+ msg.MsgID+"-"+wsGenerateID(), item.Image.URL, item.Image.AESKey, ".jpg")
+ if err != nil {
+ logger.WarnCF("wecom_aibot", "Failed to download/store mixed image",
+ map[string]any{"error": err.Error()})
+ } else {
+ mediaRefs = append(mediaRefs, ref)
+ }
+ }
+ default:
+ logger.WarnCF("wecom_aibot", "Unsupported item type in mixed message",
+ map[string]any{"msgtype": item.MsgType})
+ }
+ }
+
+ if len(textParts) == 0 && len(mediaRefs) == 0 {
+ logger.WarnC("wecom_aibot", "Mixed message has no usable content")
+ c.wsSendStreamFinish(reqID, wsGenerateID(), "Mixed message type is not yet fully supported.")
+ return
+ }
+
+ content := strings.Join(textParts, "\n")
+ if content == "" {
+ content = "[images]"
+ }
+ c.dispatchWSAgentTask(reqID, msg, content, mediaRefs)
+}
+
+// dispatchWSAgentTask registers a new agent task, sends the opening stream frame,
+// and starts a goroutine that runs the agent and streams the reply back.
+// content is the text forwarded to the agent; mediaRefs are optional media
+// store references attached to the inbound message.
+func (c *WeComAIBotWSChannel) dispatchWSAgentTask(
+ reqID string,
+ msg WeComAIBotWSMessage,
+ content string,
+ mediaRefs []string,
+) {
+ userID := msg.From.UserID
+ if userID == "" {
+ userID = "unknown"
+ }
+ // actualChatID is the real WeCom chat/user ID used for peer identification.
+ // reqID is used as the routing chatID so each turn is independently addressable.
+ actualChatID := wsChatID(msg)
+
+ streamID := wsGenerateID()
+ chatType := wsChatTypeValue(msg.ChatType)
+ taskCtx, taskCancel := context.WithCancel(c.ctx)
+
+ task := &wsTask{
+ ReqID: reqID,
+ ChatID: actualChatID,
+ ChatType: chatType,
+ StreamID: streamID,
+ answerCh: make(chan string, 1),
+ ctx: taskCtx,
+ cancel: taskCancel,
+ }
+ // Each req_id is unique per WeCom turn; tasks run concurrently, no cancellation.
+ c.setReqState(reqID, &wsReqState{
+ Task: task,
+ Route: wsLateReplyRoute{
+ ChatID: actualChatID,
+ ChatType: chatType,
+ ReadyAt: time.Now().Add(wsStreamMaxDuration),
+ ExpiresAt: time.Now().Add(wsLateReplyRouteTTL),
+ },
+ })
+
+ logger.DebugCF("wecom_aibot", "Registered new agent task",
+ map[string]any{"chat_id": actualChatID, "req_id": reqID, "stream_id": streamID})
+
+ // Send an empty stream opening frame (finish=false) immediately.
+ c.wsSendStreamChunk(reqID, streamID, false, "")
+
+ go func() {
+ defer func() {
+ taskCancel()
+ c.clearReqTask(reqID, task)
+ }()
+
+ sender := bus.SenderInfo{
+ Platform: "wecom_aibot",
+ PlatformID: userID,
+ CanonicalID: identity.BuildCanonicalID("wecom_aibot", userID),
+ DisplayName: userID,
+ }
+ peerKind := "direct"
+ if msg.ChatType == "group" {
+ peerKind = "group"
+ }
+ peer := bus.Peer{Kind: peerKind, ID: actualChatID}
+ metadata := map[string]string{
+ "channel": "wecom_aibot",
+ "chat_id": actualChatID,
+ "chat_type": msg.ChatType,
+ "msg_type": msg.MsgType,
+ "msgid": msg.MsgID,
+ "aibotid": msg.AIBotID,
+ "stream_id": streamID,
+ }
+ // Pass reqID as chatID: OutboundMessage.ChatID = reqID → Send() finds tasks[reqID].
+ c.HandleMessage(taskCtx, peer, reqID, userID, reqID,
+ content, mediaRefs, metadata, sender)
+
+ // Wait for the agent reply. While waiting, send periodic finish=false
+ // hints so the user knows processing is still in progress.
+ // WeCom requires finish=true within 6 minutes of the first stream frame;
+ // wsStreamMaxDuration enforces that limit with a safety margin.
+ waitHints := []string{
+ "⏳ Processing, please wait...",
+ "⏳ Still processing, please wait...",
+ "⏳ Almost there, please wait...",
+ }
+ ticker := time.NewTicker(wsStreamTickInterval)
+ defer ticker.Stop()
+ deadlineTimer := time.NewTimer(wsStreamMaxDuration)
+ defer deadlineTimer.Stop()
+ tickCount := 0
+ for {
+ select {
+ case answer := <-task.answerCh:
+ // Split the answer into byte-bounded chunks and send as stream frames.
+ // All but the last carry finish=false; the final frame closes the stream.
+ chunks := splitWSContent(answer, wsStreamMaxContentBytes)
+ for i, chunk := range chunks {
+ c.wsSendStreamChunk(reqID, streamID, i == len(chunks)-1, chunk)
+ }
+ c.deleteReqState(reqID)
+ return
+ case <-ticker.C:
+ hint := waitHints[tickCount%len(waitHints)]
+ tickCount++
+ logger.DebugCF("wecom_aibot", "Sending stream progress hint",
+ map[string]any{"chat_id": actualChatID, "tick": tickCount})
+ c.wsSendStreamChunk(reqID, streamID, false, hint)
+ case <-deadlineTimer.C:
+ logger.WarnCF("wecom_aibot",
+ "Stream response deadline reached, closing stream; late reply will be pushed",
+ map[string]any{"chat_id": actualChatID})
+ c.wsSendStreamFinish(reqID, streamID,
+ "⏳ Processing is taking longer than expected, the response will be sent as a follow-up message.")
+ return
+ case <-taskCtx.Done():
+ // Give a short grace period so that a response queued in the bus
+ // just before cancellation can still be delivered. This closes a
+ // race where a rapid second message cancels this task after the
+ // agent already published but before Send() wrote to answerCh.
+ //
+ // The connection is gone at this point, so we cannot use
+ // wsSendStreamFinish. Try wsSendActivePush on the (possibly
+ // already-restored) connection; if that also fails, leave the
+ // route intact so Send() can push the reply once reconnected.
+ select {
+ case answer := <-task.answerCh:
+ if err := c.wsSendActivePush(task.ChatID, task.ChatType, answer); err != nil {
+ logger.WarnCF("wecom_aibot",
+ "Grace-period push failed after task cancellation; reply may be lost",
+ map[string]any{"req_id": reqID, "chat_id": task.ChatID, "error": err.Error()})
+ } else {
+ c.deleteReqState(reqID)
+ }
+ case <-time.After(100 * time.Millisecond):
+ }
+ return
+ }
+ }
+ }()
+}
+
+// handleWSVoiceMessage handles voice messages.
+// WeCom transcribes voice to text in the callback; if the transcription is
+// present it is dispatched as plain text to the agent.
+func (c *WeComAIBotWSChannel) handleWSVoiceMessage(reqID string, msg WeComAIBotWSMessage) {
+ if msg.Voice != nil && msg.Voice.Content != "" {
+ c.dispatchWSAgentTask(reqID, msg, msg.Voice.Content, nil)
+ return
+ }
+ c.wsSendStreamFinish(reqID, wsGenerateID(), "Voice messages are not yet supported.")
+}
+
+// handleWSFileMessage handles file messages.
+func (c *WeComAIBotWSChannel) handleWSFileMessage(reqID string, msg WeComAIBotWSMessage) {
+ if msg.File == nil {
+ logger.WarnC("wecom_aibot", "File message missing file field")
+ c.wsSendStreamFinish(reqID, wsGenerateID(), "File message could not be processed.")
+ return
+ }
+ c.wsHandleMediaMessage(reqID, msg, msg.File.URL, msg.File.AESKey, "file")
+}
+
+// handleWSVideoMessage handles video messages.
+func (c *WeComAIBotWSChannel) handleWSVideoMessage(reqID string, msg WeComAIBotWSMessage) {
+ if msg.Video == nil {
+ logger.WarnC("wecom_aibot", "Video message missing video field")
+ c.wsSendStreamFinish(reqID, wsGenerateID(), "Video message could not be processed.")
+ return
+ }
+ c.wsHandleMediaMessage(reqID, msg, msg.Video.URL, msg.Video.AESKey, "video")
+}
+
+// ---- WebSocket write helpers ----
+
+// wsSendStreamChunk sends an aibot_respond_msg stream frame.
+func (c *WeComAIBotWSChannel) wsSendStreamChunk(reqID, streamID string, finish bool, content string) {
+ logger.DebugCF("wecom_aibot", "Sending stream chunk", map[string]any{
+ "stream_id": streamID,
+ "finish": finish,
+ "preview": utils.Truncate(content, 100),
+ })
+ cmd := wsCommand{
+ Cmd: "aibot_respond_msg",
+ Headers: wsHeaders{ReqID: reqID},
+ Body: wsRespondMsgBody{
+ MsgType: "stream",
+ Stream: &wsStreamContent{
+ ID: streamID,
+ Finish: finish,
+ Content: content,
+ },
+ },
+ }
+ if err := c.writeWSAndWait(cmd, wsRespondMsgTimeout); err != nil {
+ logger.WarnCF("wecom_aibot", "Stream chunk ack failed", map[string]any{
+ "req_id": reqID,
+ "stream_id": streamID,
+ "finish": finish,
+ "error": err,
+ })
+ }
+}
+
+// wsSendStreamFinish sends the final aibot_respond_msg frame (finish=true, no images).
+func (c *WeComAIBotWSChannel) wsSendStreamFinish(reqID, streamID, content string) {
+ c.wsSendStreamChunk(reqID, streamID, true, content)
+}
+
+// wsSendWelcomeMsg sends a text welcome message via aibot_respond_welcome_msg.
+func (c *WeComAIBotWSChannel) wsSendWelcomeMsg(reqID, content string) {
+ logger.DebugCF("wecom_aibot", "Sending welcome message", map[string]any{"req_id": reqID})
+ cmd := wsCommand{
+ Cmd: "aibot_respond_welcome_msg",
+ Headers: wsHeaders{ReqID: reqID},
+ Body: wsRespondMsgBody{
+ MsgType: "text",
+ Text: &wsTextContent{Content: content},
+ },
+ }
+ if err := c.writeWSAndWait(cmd, wsWelcomeMsgTimeout); err != nil {
+ logger.WarnCF("wecom_aibot", "Welcome message ack failed",
+ map[string]any{"req_id": reqID, "error": err.Error()})
+ }
+}
+
+// wsSendActivePush sends a proactive markdown message using aibot_send_msg.
+// Long content is automatically split into byte-bounded chunks (≤ wsStreamMaxContentBytes
+// each) and delivered as consecutive messages.
+// It is used as a fallback for late replies after stream response window expires.
+func (c *WeComAIBotWSChannel) wsSendActivePush(chatID string, chatType uint32, content string) error {
+ if chatID == "" {
+ return fmt.Errorf("chatid is empty")
+ }
+ for _, chunk := range splitWSContent(content, wsStreamMaxContentBytes) {
+ reqID := wsGenerateID()
+ if err := c.writeWSAndWait(wsCommand{
+ Cmd: "aibot_send_msg",
+ Headers: wsHeaders{ReqID: reqID},
+ Body: wsSendMsgBody{
+ ChatID: chatID,
+ ChatType: chatType,
+ MsgType: "markdown",
+ Markdown: &wsMarkdownContent{Content: chunk},
+ },
+ }, wsSendMsgTimeout); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// writeWSAndWait writes cmd to the active connection and validates the command response.
+func (c *WeComAIBotWSChannel) writeWSAndWait(cmd wsCommand, timeout time.Duration) error {
+ if cmd.Headers.ReqID == "" {
+ return fmt.Errorf("req_id is empty")
+ }
+
+ c.connMu.Lock()
+ conn := c.conn
+ c.connMu.Unlock()
+ if conn == nil {
+ return fmt.Errorf("websocket not connected")
+ }
+
+ resp, err := c.sendAndWait(conn, cmd.Headers.ReqID, cmd, timeout)
+ if err != nil {
+ return err
+ }
+ if resp.ErrCode != 0 {
+ return fmt.Errorf("%s rejected (errcode=%d): %s", cmd.Cmd, resp.ErrCode, resp.ErrMsg)
+ }
+ return nil
+}
+
+// cancelAllTasks cancels every pending agent task; called when the connection drops.
+// It also expires each task's stream window (ReadyAt = now) so that when the agent
+// eventually delivers its reply via Send(), the message is forwarded via
+// wsSendActivePush on the restored connection instead of being silently discarded.
+func (c *WeComAIBotWSChannel) cancelAllTasks() {
+ c.reqStatesMu.Lock()
+ defer c.reqStatesMu.Unlock()
+ now := time.Now()
+ for _, state := range c.reqStates {
+ if state != nil && state.Task != nil {
+ state.Task.cancel()
+ state.Task = nil
+ // Expire the stream window immediately so Send() uses wsSendActivePush.
+ state.Route.ReadyAt = now
+ }
+ }
+}
+
+func (c *WeComAIBotWSChannel) setReqState(reqID string, state *wsReqState) {
+ c.reqStatesMu.Lock()
+ defer c.reqStatesMu.Unlock()
+ now := time.Now()
+ for k, v := range c.reqStates {
+ if v == nil || now.After(v.Route.ExpiresAt) {
+ delete(c.reqStates, k)
+ }
+ }
+ c.reqStates[reqID] = state
+}
+
+func (c *WeComAIBotWSChannel) getReqState(reqID string) (*wsTask, wsLateReplyRoute, bool) {
+ c.reqStatesMu.Lock()
+ defer c.reqStatesMu.Unlock()
+ state, ok := c.reqStates[reqID]
+ if !ok || state == nil {
+ return nil, wsLateReplyRoute{}, false
+ }
+ if time.Now().After(state.Route.ExpiresAt) {
+ delete(c.reqStates, reqID)
+ return nil, wsLateReplyRoute{}, false
+ }
+ return state.Task, state.Route, true
+}
+
+func (c *WeComAIBotWSChannel) deleteReqState(reqID string) {
+ c.reqStatesMu.Lock()
+ delete(c.reqStates, reqID)
+ c.reqStatesMu.Unlock()
+}
+
+func (c *WeComAIBotWSChannel) clearReqTask(reqID string, task *wsTask) {
+ c.reqStatesMu.Lock()
+ defer c.reqStatesMu.Unlock()
+ state, ok := c.reqStates[reqID]
+ if !ok || state == nil {
+ return
+ }
+ if state.Task == task {
+ state.Task = nil
+ }
+}
+
+func wsChatTypeValue(chatType string) uint32 {
+ if chatType == "group" {
+ return 2
+ }
+ return 1
+}
+
+// wsChatID returns the effective chat ID from a WS message.
+// For group messages it is msg.ChatID; for single chats it falls back to the sender's UserID.
+func wsChatID(msg WeComAIBotWSMessage) string {
+ if msg.ChatID != "" {
+ return msg.ChatID
+ }
+ return msg.From.UserID
+}
+
+// wsGenerateID generates a random 10-character alphanumeric ID.
+// It is package-level (not a method) so it can be shared by both channel modes.
+func wsGenerateID() string {
+ return generateRandomID(10)
+}
+
+// ---- Inbound media download helpers ----
+
+// storeWSMedia downloads the resource at resourceURL (with optional AES-CBC
+// decryption) and stores it in the MediaStore. The file extension is inferred
+// from the HTTP Content-Type response header; defaultExt is used as a fallback
+// when the content type is absent or unrecognized.
+func (c *WeComAIBotWSChannel) storeWSMedia(
+ ctx context.Context,
+ chatID, msgID, resourceURL, aesKey, defaultExt string,
+) (string, error) {
+ store := c.GetMediaStore()
+ if store == nil {
+ return "", fmt.Errorf("no media store available")
+ }
+
+ const maxSize = 20 << 20 // 20 MB
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil)
+ if err != nil {
+ return "", fmt.Errorf("create request: %w", err)
+ }
+ resp, err := wsImageHTTPClient.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("download: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("download HTTP %d", resp.StatusCode)
+ }
+
+ // Infer file extension from the Content-Type response header.
+ ext := wsMediaExtFromContentType(resp.Header.Get("Content-Type"))
+ if ext == "" {
+ ext = defaultExt
+ }
+
+ // Buffer the media in memory, bounded to maxSize.
+ data, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxSize)+1))
+ if err != nil {
+ return "", fmt.Errorf("read media: %w", err)
+ }
+ if len(data) > maxSize {
+ return "", fmt.Errorf("media too large (> %d MB)", maxSize>>20)
+ }
+
+ // AES-CBC decryption if a key is present.
+ if aesKey != "" {
+ key, decErr := base64.StdEncoding.DecodeString(aesKey)
+ if decErr != nil || len(key) != 32 {
+ key, decErr = decodeWeComAESKey(aesKey)
+ if decErr != nil {
+ return "", fmt.Errorf("decode media AES key: %w", decErr)
+ }
+ }
+ data, err = decryptAESCBC(key, data)
+ if err != nil {
+ return "", fmt.Errorf("decrypt media: %w", err)
+ }
+ }
+
+ // Write to a temp file. The file is owned by the MediaStore and deleted by
+ // store.ReleaseAll — no caller-side cleanup needed.
+ mediaDir := filepath.Join(os.TempDir(), "picoclaw_media")
+ if err = os.MkdirAll(mediaDir, 0o700); err != nil {
+ return "", fmt.Errorf("mkdir: %w", err)
+ }
+ tmpFile, err := os.CreateTemp(mediaDir, msgID+"-*"+ext)
+ if err != nil {
+ return "", fmt.Errorf("create temp file: %w", err)
+ }
+ tmpPath := tmpFile.Name()
+ _, writeErr := tmpFile.Write(data)
+ closeErr := tmpFile.Close()
+ if writeErr != nil {
+ os.Remove(tmpPath)
+ return "", fmt.Errorf("write media: %w", writeErr)
+ }
+ if closeErr != nil {
+ os.Remove(tmpPath)
+ return "", fmt.Errorf("close media: %w", closeErr)
+ }
+
+ scope := channels.BuildMediaScope("wecom_aibot", chatID, msgID)
+ ref, err := store.Store(tmpPath, media.MediaMeta{
+ Filename: msgID + ext,
+ Source: "wecom_aibot",
+ }, scope)
+ if err != nil {
+ os.Remove(tmpPath)
+ return "", fmt.Errorf("store: %w", err)
+ }
+ return ref, nil
+}
+
+// wsMediaExtFromContentType returns the lowercase file extension (with leading
+// dot) for the given Content-Type value, or "" when the type is unrecognized.
+func wsMediaExtFromContentType(contentType string) string {
+ if contentType == "" {
+ return ""
+ }
+ // Strip parameters (e.g. "image/jpeg; charset=utf-8" → "image/jpeg").
+ mt := strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0]))
+ switch mt {
+ case "image/jpeg", "image/jpg":
+ return ".jpg"
+ case "image/png":
+ return ".png"
+ case "image/gif":
+ return ".gif"
+ case "image/webp":
+ return ".webp"
+ case "video/mp4":
+ return ".mp4"
+ case "video/mpeg", "video/x-mpeg":
+ return ".mpeg"
+ case "video/quicktime":
+ return ".mov"
+ case "video/webm":
+ return ".webm"
+ case "audio/mpeg", "audio/mp3":
+ return ".mp3"
+ case "audio/ogg":
+ return ".ogg"
+ case "audio/wav":
+ return ".wav"
+ case "application/pdf":
+ return ".pdf"
+ case "application/zip":
+ return ".zip"
+ case "application/x-rar-compressed", "application/vnd.rar":
+ return ".rar"
+ case "text/plain":
+ return ".txt"
+ case "application/msword":
+ return ".doc"
+ case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
+ return ".docx"
+ case "application/vnd.ms-excel":
+ return ".xls"
+ case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
+ return ".xlsx"
+ case "application/vnd.ms-powerpoint":
+ return ".ppt"
+ case "application/vnd.openxmlformats-officedocument.presentationml.presentation":
+ return ".pptx"
+ }
+ return ""
+}
+
+// wsLabelToDefaultExt returns the default file extension for the given media label
+// used in wsHandleMediaMessage. It is the fallback when Content-Type detection fails.
+func wsLabelToDefaultExt(label string) string {
+ switch label {
+ case "image":
+ return ".jpg"
+ case "video":
+ return ".mp4"
+ default: // "file" and any future labels
+ return ".bin"
+ }
+}
+
+// ---- Content length helpers ----
+
+// splitWSContent splits content into chunks each fitting within maxBytes UTF-8
+// bytes, preserving code block integrity via channels.SplitMessage.
+// When SplitMessage still produces an oversized chunk (e.g. dense CJK content),
+// splitAtByteBoundary is applied as a last-resort byte-level fallback.
+func splitWSContent(content string, maxBytes int) []string {
+ if len(content) <= maxBytes {
+ return []string{content}
+ }
+ // SplitMessage works in runes. Use maxBytes as the rune limit: for pure ASCII
+ // this is exact; for multibyte content the byte verification below catches
+ // any chunk that still overflows.
+ chunks := channels.SplitMessage(content, maxBytes)
+ var result []string
+ for _, chunk := range chunks {
+ if len(chunk) <= maxBytes {
+ result = append(result, chunk)
+ } else {
+ // Still too large in bytes (e.g. dense CJK); force-split at UTF-8 boundaries.
+ result = append(result, splitAtByteBoundary(chunk, maxBytes)...)
+ }
+ }
+ return result
+}
+
+// splitAtByteBoundary splits s into parts each ≤ maxBytes bytes by walking back
+// from the hard byte limit to find a valid UTF-8 rune start boundary.
+// This is a last-resort fallback; it does not try to preserve code blocks.
+func splitAtByteBoundary(s string, maxBytes int) []string {
+ var parts []string
+ for len(s) > maxBytes {
+ end := maxBytes
+ // Walk back past any UTF-8 continuation bytes (high two bits == 10).
+ for end > 0 && s[end]>>6 == 0b10 {
+ end--
+ }
+ if end == 0 {
+ end = maxBytes // shouldn't happen with valid UTF-8
+ }
+ parts = append(parts, s[:end])
+ s = strings.TrimLeft(s[end:], " \t\n\r")
+ }
+ if s != "" {
+ parts = append(parts, s)
+ }
+ return parts
+}
diff --git a/pkg/channels/wecom/aibot_ws_test.go b/pkg/channels/wecom/aibot_ws_test.go
new file mode 100644
index 000000000..0a533da5d
--- /dev/null
+++ b/pkg/channels/wecom/aibot_ws_test.go
@@ -0,0 +1,295 @@
+package wecom
+
+import (
+ "bytes"
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/media"
+)
+
+// newTestWSChannel creates a WeComAIBotWSChannel ready for unit testing.
+func newTestWSChannel(t *testing.T) *WeComAIBotWSChannel {
+ t.Helper()
+ cfg := config.WeComAIBotConfig{
+ Enabled: true,
+ BotID: "test_bot_id",
+ Secret: "test_secret",
+ }
+ ch, err := newWeComAIBotWSChannel(cfg, bus.NewMessageBus())
+ if err != nil {
+ t.Fatalf("create WS channel: %v", err)
+ }
+ return ch
+}
+
+// TestStoreWSMedia_NilStore verifies that storeWSMedia returns an error when no
+// MediaStore has been injected.
+func TestStoreWSMedia_NilStore(t *testing.T) {
+ ch := newTestWSChannel(t)
+ _, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", "http://any", "", ".jpg")
+ if err == nil {
+ t.Fatal("expected error when no MediaStore is set")
+ }
+}
+
+// TestStoreWSMedia_HTTPError verifies that storeWSMedia propagates HTTP errors
+// from the media server.
+func TestStoreWSMedia_HTTPError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ http.Error(w, "not found", http.StatusNotFound)
+ }))
+ defer srv.Close()
+
+ ch := newTestWSChannel(t)
+ ch.SetMediaStore(media.NewFileMediaStore())
+
+ _, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", srv.URL, "", ".jpg")
+ if err == nil {
+ t.Fatal("expected error for HTTP 404")
+ }
+}
+
+// TestStoreWSMedia_ServerUnavailable verifies that storeWSMedia returns a clear
+// error when the media server cannot be reached.
+func TestStoreWSMedia_ServerUnavailable(t *testing.T) {
+ ch := newTestWSChannel(t)
+ ch.SetMediaStore(media.NewFileMediaStore())
+
+ // Port 1 is reserved and will refuse the connection immediately.
+ _, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", "http://127.0.0.1:1", "", ".jpg")
+ if err == nil {
+ t.Fatal("expected error for unreachable server")
+ }
+}
+
+// TestStoreWSMedia_Success_NoAES verifies the happy path: the media is downloaded,
+// a media ref is returned, and the file persists and is readable via Resolve until
+// ReleaseAll is called. The server returns no Content-Type, so the defaultExt is used.
+func TestStoreWSMedia_Success_NoAES(t *testing.T) {
+ imageData := bytes.Repeat([]byte("x"), 256)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(imageData)
+ }))
+ defer srv.Close()
+
+ ch := newTestWSChannel(t)
+ store := media.NewFileMediaStore()
+ ch.SetMediaStore(store)
+
+ ref, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", srv.URL, "", ".jpg")
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if ref == "" {
+ t.Fatal("expected non-empty ref")
+ }
+
+ // File must be accessible after storeWSMedia returns (no premature deletion).
+ path, err := store.Resolve(ref)
+ if err != nil {
+ t.Fatalf("ref should resolve: %v", err)
+ }
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("file should exist at %s: %v", path, err)
+ }
+ if !bytes.Equal(got, imageData) {
+ t.Errorf("content mismatch: got len=%d, want len=%d", len(got), len(imageData))
+ }
+
+ // ReleaseAll must delete the file (store owns lifecycle).
+ scope := channels.BuildMediaScope("wecom_aibot", "chat1", "msg1")
+ if err := store.ReleaseAll(scope); err != nil {
+ t.Fatalf("ReleaseAll failed: %v", err)
+ }
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Errorf("file should have been deleted by ReleaseAll, stat err: %v", err)
+ }
+}
+
+// TestStoreWSMedia_MultipleMessages verifies that concurrent media messages with
+// different msgIDs do not collide and each resolve to distinct files.
+func TestStoreWSMedia_MultipleMessages(t *testing.T) {
+ imageA := bytes.Repeat([]byte("a"), 64)
+ imageB := bytes.Repeat([]byte("b"), 64)
+
+ srvA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(imageA)
+ }))
+ defer srvA.Close()
+ srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(imageB)
+ }))
+ defer srvB.Close()
+
+ ch := newTestWSChannel(t)
+ store := media.NewFileMediaStore()
+ ch.SetMediaStore(store)
+
+ refA, err := ch.storeWSMedia(context.Background(), "chat1", "msgA", srvA.URL, "", ".jpg")
+ if err != nil {
+ t.Fatalf("storeWSMedia A: %v", err)
+ }
+ refB, err := ch.storeWSMedia(context.Background(), "chat1", "msgB", srvB.URL, "", ".jpg")
+ if err != nil {
+ t.Fatalf("storeWSMedia B: %v", err)
+ }
+ if refA == refB {
+ t.Fatal("distinct messages must produce distinct refs")
+ }
+
+ pathA, _ := store.Resolve(refA)
+ pathB, _ := store.Resolve(refB)
+ if pathA == pathB {
+ t.Fatal("distinct messages must be stored at distinct paths")
+ }
+
+ gotA, _ := os.ReadFile(pathA)
+ gotB, _ := os.ReadFile(pathB)
+ if !bytes.Equal(gotA, imageA) {
+ t.Errorf("content mismatch for message A")
+ }
+ if !bytes.Equal(gotB, imageB) {
+ t.Errorf("content mismatch for message B")
+ }
+}
+
+// TestStoreWSMedia_ContentTypeExt verifies that the file extension is inferred
+// from the HTTP Content-Type header and the defaultExt fallback is used when the
+// type is absent or unrecognized.
+func TestStoreWSMedia_ContentTypeExt(t *testing.T) {
+ tests := []struct {
+ contentType string
+ wantExt string
+ }{
+ {"image/jpeg", ".jpg"},
+ {"image/png", ".png"},
+ {"video/mp4", ".mp4"},
+ {"application/pdf", ".pdf"},
+ {"application/zip", ".zip"},
+ // With parameters stripped.
+ {"video/mp4; codecs=avc1", ".mp4"},
+ // Unknown type → falls back to defaultExt.
+ {"", ""},
+ {"application/octet-stream", ""},
+ }
+ for _, tc := range tests {
+ got := wsMediaExtFromContentType(tc.contentType)
+ if got != tc.wantExt {
+ t.Errorf("wsMediaExtFromContentType(%q) = %q, want %q", tc.contentType, got, tc.wantExt)
+ }
+ }
+
+ // End-to-end: server returns Content-Type: video/mp4, defaultExt is .bin.
+ // The stored file should carry the .mp4 extension, not .bin.
+ payload := bytes.Repeat([]byte("v"), 128)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "video/mp4")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(payload)
+ }))
+ defer srv.Close()
+
+ ch := newTestWSChannel(t)
+ store := media.NewFileMediaStore()
+ ch.SetMediaStore(store)
+
+ ref, err := ch.storeWSMedia(context.Background(), "chat1", "vid1", srv.URL, "", ".bin")
+ if err != nil {
+ t.Fatalf("storeWSMedia: %v", err)
+ }
+ path, err := store.Resolve(ref)
+ if err != nil {
+ t.Fatalf("resolve: %v", err)
+ }
+ if ext := path[len(path)-4:]; ext != ".mp4" {
+ t.Errorf("expected .mp4 extension from Content-Type, got %q", ext)
+ }
+}
+
+// TestSplitWSContent verifies byte-aware splitting of stream content.
+func TestSplitWSContent(t *testing.T) {
+ t.Run("short content is not split", func(t *testing.T) {
+ chunks := splitWSContent("hello", 20480)
+ if len(chunks) != 1 || chunks[0] != "hello" {
+ t.Fatalf("unexpected chunks: %v", chunks)
+ }
+ })
+
+ t.Run("ASCII content split at byte boundary", func(t *testing.T) {
+ // Build a string just over the limit.
+ content := strings.Repeat("a", 20481)
+ chunks := splitWSContent(content, 20480)
+ if len(chunks) < 2 {
+ t.Fatalf("expected >= 2 chunks, got %d", len(chunks))
+ }
+ for i, c := range chunks {
+ if len(c) > 20480 {
+ t.Errorf("chunk %d has %d bytes, want <= 20480", i, len(c))
+ }
+ }
+ // Reassembled content must equal the original (possibly without leading
+ // whitespace that splitWSContent trims between chunks).
+ joined := strings.Join(chunks, "")
+ if len(joined) < len(content)-len(chunks) {
+ t.Errorf("joined length %d too short (original %d)", len(joined), len(content))
+ }
+ })
+
+ t.Run("CJK content split within byte limit", func(t *testing.T) {
+ // Each CJK rune is 3 bytes in UTF-8.
+ // 7000 CJK chars = 21000 bytes, which exceeds 20480.
+ content := strings.Repeat("\u4e2d", 7000)
+ chunks := splitWSContent(content, 20480)
+ if len(chunks) < 2 {
+ t.Fatalf("expected >= 2 chunks for 21000-byte CJK content, got %d", len(chunks))
+ }
+ for i, c := range chunks {
+ if len(c) > 20480 {
+ t.Errorf("chunk %d has %d bytes, want <= 20480", i, len(c))
+ }
+ // Every chunk must be valid UTF-8.
+ if !strings.ContainsRune(c, '\u4e2d') && len(c) > 0 {
+ // quick plausibility check — content was pure CJK
+ }
+ }
+ })
+}
+
+// TestSplitAtByteBoundary verifies the last-resort byte-boundary splitter.
+func TestSplitAtByteBoundary(t *testing.T) {
+ t.Run("ASCII fits in one chunk", func(t *testing.T) {
+ parts := splitAtByteBoundary("hello world", 100)
+ if len(parts) != 1 {
+ t.Fatalf("expected 1 part, got %d", len(parts))
+ }
+ })
+
+ t.Run("splits at byte boundary, never mid-rune", func(t *testing.T) {
+ // 10 CJK characters = 30 bytes; split at 20 bytes.
+ s := strings.Repeat("\u6587", 10) // 10 × 3 bytes = 30 bytes
+ parts := splitAtByteBoundary(s, 20)
+ for i, p := range parts {
+ if len(p) > 20 {
+ t.Errorf("part %d has %d bytes, want <= 20", i, len(p))
+ }
+ // Must be valid UTF-8 (no torn multi-byte sequences).
+ for j, r := range p {
+ if r == '\uFFFD' {
+ t.Errorf("part %d has replacement rune at position %d: torn UTF-8", i, j)
+ }
+ }
+ }
+ })
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 947af14a6..d226bba51 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -474,15 +474,17 @@ type WeComAppConfig struct {
}
type WeComAIBotConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
- EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
- ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
- MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps
- WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
+ BotID string `json:"bot_id,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_BOT_ID"`
+ Secret string `json:"secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_SECRET"`
+ Token string `json:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
+ EncodingAESKey string `json:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
+ WebhookPath string `json:"webhook_path,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
+ ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
+ MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"`
+ WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
}
type PicoConfig struct {
From 844a4eefc7ae1c26bb12490495c736d5bf3b8550 Mon Sep 17 00:00:00 2001
From: SakoroYou <165740095+Sakurapainting@users.noreply.github.com>
Date: Thu, 19 Mar 2026 21:11:36 +0800
Subject: [PATCH 04/44] fix(agent): avoid process exit on exec init failure and
add regression test (#1784)
* fix(agent): make exec tool init failure non-fatal
* test(agent): add regression test for invalid exec config fallback
---
pkg/agent/instance.go | 20 ++++++++++++--------
pkg/agent/instance_test.go | 34 ++++++++++++++++++++++++++++++++++
2 files changed, 46 insertions(+), 8 deletions(-)
diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go
index 1c3635322..d2a4f81a4 100644
--- a/pkg/agent/instance.go
+++ b/pkg/agent/instance.go
@@ -3,13 +3,13 @@ package agent
import (
"context"
"fmt"
- "log"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/providers"
@@ -85,9 +85,11 @@ func NewAgentInstance(
if cfg.Tools.IsToolEnabled("exec") {
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg, allowReadPaths)
if err != nil {
- log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
+ logger.ErrorCF("agent", "Failed to initialize exec tool; continuing without exec",
+ map[string]any{"error": err.Error()})
+ } else {
+ toolsRegistry.Register(execTool)
}
- toolsRegistry.Register(execTool)
}
if cfg.Tools.IsToolEnabled("edit_file") {
@@ -210,8 +212,8 @@ func NewAgentInstance(
})
lightCandidates = resolved
} else {
- log.Printf("routing: light_model %q not found in model_list — routing disabled for agent %q",
- rc.LightModel, agentID)
+ logger.WarnCF("agent", "Routing light model not found; routing disabled",
+ map[string]any{"light_model": rc.LightModel, "agent_id": agentID})
}
}
@@ -320,7 +322,8 @@ func (a *AgentInstance) Close() error {
func initSessionStore(dir string) session.SessionStore {
store, err := memory.NewJSONLStore(dir)
if err != nil {
- log.Printf("memory: init store: %v; using json sessions", err)
+ logger.WarnCF("agent", "Memory JSONL store init failed; falling back to json sessions",
+ map[string]any{"error": err.Error()})
return session.NewSessionManager(dir)
}
@@ -328,11 +331,12 @@ func initSessionStore(dir string) session.SessionStore {
// Migration failure means the store could not write data.
// Fall back to SessionManager to avoid a split state where
// some sessions are in JSONL and others remain in JSON.
- log.Printf("memory: migration failed: %v; falling back to json sessions", merr)
+ logger.WarnCF("agent", "Memory migration failed; falling back to json sessions",
+ map[string]any{"error": merr.Error()})
store.Close()
return session.NewSessionManager(dir)
} else if n > 0 {
- log.Printf("memory: migrated %d session(s) to jsonl", n)
+ logger.InfoCF("agent", "Memory migrated to JSONL", map[string]any{"sessions_migrated": n})
}
return session.NewJSONLBackend(store)
diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go
index 5a13c8f1b..b3318ad1f 100644
--- a/pkg/agent/instance_test.go
+++ b/pkg/agent/instance_test.go
@@ -246,3 +246,37 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
t.Fatalf("exec output missing media content: %s", execResult.ForLLM)
}
}
+
+func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) {
+ workspace := t.TempDir()
+
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: workspace,
+ ModelName: "test-model",
+ },
+ },
+ Tools: config.ToolsConfig{
+ ReadFile: config.ReadFileToolConfig{Enabled: true},
+ Exec: config.ExecConfig{
+ ToolConfig: config.ToolConfig{Enabled: true},
+ EnableDenyPatterns: true,
+ CustomDenyPatterns: []string{"[invalid-regex"},
+ },
+ },
+ }
+
+ agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{})
+ if agent == nil {
+ t.Fatal("expected agent instance, got nil")
+ }
+
+ if _, ok := agent.Tools.Get("exec"); ok {
+ t.Fatal("exec tool should not be registered when exec config is invalid")
+ }
+
+ if _, ok := agent.Tools.Get("read_file"); !ok {
+ t.Fatal("read_file tool should still be registered")
+ }
+}
From 38e1fe435a1a0431bd44452c50c22bd3f85b1c09 Mon Sep 17 00:00:00 2001
From: Bijin <38134380+sliverp@users.noreply.github.com>
Date: Thu, 19 Mar 2026 21:24:46 +0800
Subject: [PATCH 05/44] fix(config): model_list inherits api_key/api_base from
providers (#1786)
When both providers and model_list are configured, model_list entries
with empty api_key or api_base now automatically inherit from the
matching provider (matched by protocol prefix in the Model field).
Example: a model_list entry with model='deepseek/deepseek-chat' and
no api_key will inherit from providers.deepseek.api_key.
Explicit model_list values always take precedence.
Changes:
- Add InheritProviderCredentials() in migration.go
- Call it in LoadConfig() after provider-to-model-list conversion
- Add protocolProviderMapping for all 25 supported protocols
- 6 new tests covering inheritance, precedence, and edge cases
Closes #1635
---
pkg/config/config.go | 9 +++
pkg/config/migration.go | 81 ++++++++++++++++++++
pkg/config/migration_test.go | 140 +++++++++++++++++++++++++++++++++++
3 files changed, 230 insertions(+)
diff --git a/pkg/config/config.go b/pkg/config/config.go
index d226bba51..4f8026d27 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -916,6 +916,15 @@ func LoadConfig(path string) (*Config, error) {
cfg.ModelList = ConvertProvidersToModelList(cfg)
}
+ // Inherit credentials from providers to model_list entries (#1635).
+ // When both providers and model_list are present, model_list entries
+ // whose api_key/api_base are empty will inherit from the matching
+ // provider (matched by protocol prefix). Explicit model_list values
+ // always take precedence.
+ if cfg.HasProvidersConfig() {
+ InheritProviderCredentials(cfg.ModelList, cfg.Providers)
+ }
+
// Validate model_list for uniqueness and required fields
if err := cfg.ValidateModelList(); err != nil {
return nil, err
diff --git a/pkg/config/migration.go b/pkg/config/migration.go
index c7fc214d5..832d8bf17 100644
--- a/pkg/config/migration.go
+++ b/pkg/config/migration.go
@@ -468,3 +468,84 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return result
}
+
+// protocolProviderMapping maps a model protocol prefix (the part before "/" in
+// the Model field) to a function that extracts the corresponding ProviderConfig
+// from the legacy ProvidersConfig. Used by InheritProviderCredentials.
+var protocolProviderMapping = map[string]func(p ProvidersConfig) ProviderConfig{
+ "openai": func(p ProvidersConfig) ProviderConfig { return p.OpenAI.ProviderConfig },
+ "anthropic": func(p ProvidersConfig) ProviderConfig { return p.Anthropic },
+ "litellm": func(p ProvidersConfig) ProviderConfig { return p.LiteLLM },
+ "openrouter": func(p ProvidersConfig) ProviderConfig { return p.OpenRouter },
+ "groq": func(p ProvidersConfig) ProviderConfig { return p.Groq },
+ "zhipu": func(p ProvidersConfig) ProviderConfig { return p.Zhipu },
+ "vllm": func(p ProvidersConfig) ProviderConfig { return p.VLLM },
+ "gemini": func(p ProvidersConfig) ProviderConfig { return p.Gemini },
+ "nvidia": func(p ProvidersConfig) ProviderConfig { return p.Nvidia },
+ "ollama": func(p ProvidersConfig) ProviderConfig { return p.Ollama },
+ "moonshot": func(p ProvidersConfig) ProviderConfig { return p.Moonshot },
+ "shengsuanyun": func(p ProvidersConfig) ProviderConfig { return p.ShengSuanYun },
+ "deepseek": func(p ProvidersConfig) ProviderConfig { return p.DeepSeek },
+ "cerebras": func(p ProvidersConfig) ProviderConfig { return p.Cerebras },
+ "vivgrid": func(p ProvidersConfig) ProviderConfig { return p.Vivgrid },
+ "volcengine": func(p ProvidersConfig) ProviderConfig { return p.VolcEngine },
+ "github-copilot": func(p ProvidersConfig) ProviderConfig { return p.GitHubCopilot },
+ "antigravity": func(p ProvidersConfig) ProviderConfig { return p.Antigravity },
+ "qwen": func(p ProvidersConfig) ProviderConfig { return p.Qwen },
+ "mistral": func(p ProvidersConfig) ProviderConfig { return p.Mistral },
+ "avian": func(p ProvidersConfig) ProviderConfig { return p.Avian },
+ "minimax": func(p ProvidersConfig) ProviderConfig { return p.Minimax },
+ "longcat": func(p ProvidersConfig) ProviderConfig { return p.LongCat },
+ "modelscope": func(p ProvidersConfig) ProviderConfig { return p.ModelScope },
+ "novita": func(p ProvidersConfig) ProviderConfig { return p.Novita },
+}
+
+// InheritProviderCredentials fills in missing api_key, api_base, proxy, and
+// request_timeout on model_list entries from the matching legacy providers
+// configuration. The match is determined by the protocol prefix in the Model
+// field (e.g. "deepseek/deepseek-chat" matches providers.deepseek).
+//
+// Only empty fields are filled — any value explicitly set on a model_list entry
+// takes precedence. This function modifies the slice in place.
+//
+// This bridges the gap described in issue #1635: users who configure
+// credentials once in the providers section expect model_list entries using
+// the same protocol to "just work" without duplicating credentials.
+func InheritProviderCredentials(models []ModelConfig, providers ProvidersConfig) {
+ if providers.IsEmpty() {
+ return
+ }
+
+ for i := range models {
+ m := &models[i]
+
+ // Extract protocol prefix from Model field
+ protocol := ""
+ if idx := strings.Index(m.Model, "/"); idx > 0 {
+ protocol = strings.ToLower(m.Model[:idx])
+ }
+ if protocol == "" {
+ continue
+ }
+
+ getProvider, ok := protocolProviderMapping[protocol]
+ if !ok {
+ continue
+ }
+ pc := getProvider(providers)
+
+ // Only fill empty fields — explicit model_list values win
+ if m.APIKey == "" && pc.APIKey != "" {
+ m.APIKey = pc.APIKey
+ }
+ if m.APIBase == "" && pc.APIBase != "" {
+ m.APIBase = pc.APIBase
+ }
+ if m.Proxy == "" && pc.Proxy != "" {
+ m.Proxy = pc.Proxy
+ }
+ if m.RequestTimeout == 0 && pc.RequestTimeout != 0 {
+ m.RequestTimeout = pc.RequestTimeout
+ }
+ }
+}
diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go
index 1b6e5b032..bea5b9034 100644
--- a/pkg/config/migration_test.go
+++ b/pkg/config/migration_test.go
@@ -613,3 +613,143 @@ func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T)
t.Errorf("Model = %q, want %q (should not duplicate prefix)", result[0].Model, "openrouter/auto")
}
}
+
+// ---------- InheritProviderCredentials tests ----------
+
+func TestInheritProviderCredentials_FillsMissingAPIKey(t *testing.T) {
+ models := []ModelConfig{
+ {ModelName: "my-deepseek", Model: "deepseek/deepseek-chat"},
+ }
+ providers := ProvidersConfig{
+ DeepSeek: ProviderConfig{
+ APIKey: "sk-deepseek-from-providers",
+ APIBase: "https://api.deepseek.com/v1",
+ },
+ }
+
+ InheritProviderCredentials(models, providers)
+
+ if models[0].APIKey != "sk-deepseek-from-providers" {
+ t.Errorf("APIKey = %q, want %q", models[0].APIKey, "sk-deepseek-from-providers")
+ }
+ if models[0].APIBase != "https://api.deepseek.com/v1" {
+ t.Errorf("APIBase = %q, want %q", models[0].APIBase, "https://api.deepseek.com/v1")
+ }
+}
+
+func TestInheritProviderCredentials_ExplicitValuesTakePrecedence(t *testing.T) {
+ models := []ModelConfig{
+ {
+ ModelName: "my-openai",
+ Model: "openai/gpt-5.4",
+ APIKey: "sk-explicit-model-key",
+ APIBase: "https://my-custom-endpoint.com/v1",
+ },
+ }
+ providers := ProvidersConfig{
+ OpenAI: OpenAIProviderConfig{
+ ProviderConfig: ProviderConfig{
+ APIKey: "sk-provider-key",
+ APIBase: "https://api.openai.com/v1",
+ },
+ },
+ }
+
+ InheritProviderCredentials(models, providers)
+
+ if models[0].APIKey != "sk-explicit-model-key" {
+ t.Errorf("APIKey = %q, want %q (explicit should win)", models[0].APIKey, "sk-explicit-model-key")
+ }
+ if models[0].APIBase != "https://my-custom-endpoint.com/v1" {
+ t.Errorf("APIBase = %q, want %q (explicit should win)", models[0].APIBase, "https://my-custom-endpoint.com/v1")
+ }
+}
+
+func TestInheritProviderCredentials_MultipleModels(t *testing.T) {
+ models := []ModelConfig{
+ {ModelName: "groq-llama", Model: "groq/llama-3.1-70b"},
+ {ModelName: "zhipu-glm", Model: "zhipu/glm-4"},
+ {ModelName: "custom-openai", Model: "openai/gpt-5.4", APIKey: "sk-already-set"},
+ }
+ providers := ProvidersConfig{
+ Groq: ProviderConfig{APIKey: "gsk-groq-key", Proxy: "http://proxy:8080"},
+ Zhipu: ProviderConfig{APIKey: "zhipu-key-123", APIBase: "https://zhipu.example.com"},
+ OpenAI: OpenAIProviderConfig{
+ ProviderConfig: ProviderConfig{APIKey: "sk-should-not-override"},
+ },
+ }
+
+ InheritProviderCredentials(models, providers)
+
+ // groq model should inherit
+ if models[0].APIKey != "gsk-groq-key" {
+ t.Errorf("groq APIKey = %q, want %q", models[0].APIKey, "gsk-groq-key")
+ }
+ if models[0].Proxy != "http://proxy:8080" {
+ t.Errorf("groq Proxy = %q, want %q", models[0].Proxy, "http://proxy:8080")
+ }
+
+ // zhipu model should inherit
+ if models[1].APIKey != "zhipu-key-123" {
+ t.Errorf("zhipu APIKey = %q, want %q", models[1].APIKey, "zhipu-key-123")
+ }
+ if models[1].APIBase != "https://zhipu.example.com" {
+ t.Errorf("zhipu APIBase = %q, want %q", models[1].APIBase, "https://zhipu.example.com")
+ }
+
+ // openai model already has key — should NOT be overridden
+ if models[2].APIKey != "sk-already-set" {
+ t.Errorf("openai APIKey = %q, want %q (should not be overridden)", models[2].APIKey, "sk-already-set")
+ }
+}
+
+func TestInheritProviderCredentials_NoMatchingProvider(t *testing.T) {
+ models := []ModelConfig{
+ {ModelName: "my-model", Model: "novelai/some-model"},
+ }
+ providers := ProvidersConfig{
+ DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
+ }
+
+ InheritProviderCredentials(models, providers)
+
+ // No matching provider for "novelai" protocol — should stay empty
+ if models[0].APIKey != "" {
+ t.Errorf("APIKey = %q, want empty (no matching provider)", models[0].APIKey)
+ }
+}
+
+func TestInheritProviderCredentials_EmptyProviders(t *testing.T) {
+ models := []ModelConfig{
+ {ModelName: "my-model", Model: "openai/gpt-5.4"},
+ }
+ providers := ProvidersConfig{} // all empty
+
+ InheritProviderCredentials(models, providers)
+
+ // Empty providers — nothing to inherit
+ if models[0].APIKey != "" {
+ t.Errorf("APIKey = %q, want empty", models[0].APIKey)
+ }
+}
+
+func TestInheritProviderCredentials_InheritsRequestTimeout(t *testing.T) {
+ models := []ModelConfig{
+ {ModelName: "my-ollama", Model: "ollama/llama3.2:3b"},
+ }
+ providers := ProvidersConfig{
+ Ollama: ProviderConfig{
+ APIBase: "http://localhost:11434",
+ RequestTimeout: 120,
+ },
+ }
+
+ InheritProviderCredentials(models, providers)
+
+ if models[0].APIBase != "http://localhost:11434" {
+ t.Errorf("APIBase = %q, want %q", models[0].APIBase, "http://localhost:11434")
+ }
+ if models[0].RequestTimeout != 120 {
+ t.Errorf("RequestTimeout = %d, want 120", models[0].RequestTimeout)
+ }
+}
From bb59518958bf519120c66c1799acb79bdd1de10c Mon Sep 17 00:00:00 2001
From: I Putu Eddy Irawan <40250580+putueddy@users.noreply.github.com>
Date: Thu, 19 Mar 2026 20:28:35 +0700
Subject: [PATCH 06/44] docs: add Indonesian (Bahasa Indonesia) README
translation (#1777)
- Rewrite README.id.md to match current upstream structure (~250 lines)
- Detailed docs moved to docs/*.md, README is quick-start only
- Sync badges (Go 1.25+, LoongArch), news (v0.2.3), Termux instructions
- Add Bahasa Indonesia + Italiano to language selectors in all 8 READMEs
---
README.fr.md | 2 +-
README.id.md | 249 ++++++++++++++++++++++++++++++++++++++++++++++++
README.it.md | 2 +-
README.ja.md | 2 +-
README.md | 2 +-
README.pt-br.md | 2 +-
README.vi.md | 2 +-
README.zh.md | 2 +-
8 files changed, 256 insertions(+), 7 deletions(-)
create mode 100644 README.id.md
diff --git a/README.fr.md b/README.fr.md
index 325c6c096..bf49ed90a 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -18,7 +18,7 @@
-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md) | **Français**
+[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)
diff --git a/README.id.md b/README.id.md
new file mode 100644
index 000000000..3f462981c
--- /dev/null
+++ b/README.id.md
@@ -0,0 +1,249 @@
+
+
+---
+
+> **PicoClaw** adalah proyek open-source independen yang diinisiasi oleh [Sipeed](https://sipeed.com). Ditulis sepenuhnya dalam **Go** — bukan fork dari OpenClaw, NanoBot, atau proyek lainnya.
+
+🦐 PicoClaw adalah asisten AI pribadi yang super ringan, terinspirasi dari [NanoBot](https://github.com/HKUDS/nanobot), ditulis ulang sepenuhnya dalam Go melalui proses "self-bootstrapping" — di mana AI Agent itu sendiri yang memandu seluruh migrasi arsitektur dan optimasi kode.
+
+⚡️ Berjalan di perangkat keras $10 dengan RAM <10MB: Hemat 99% memori dibanding OpenClaw dan 98% lebih murah dibanding Mac mini!
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+> [!CAUTION]
+> **🚨 KEAMANAN & SALURAN RESMI**
+>
+> * **TANPA KRIPTO:** PicoClaw **TIDAK** memiliki token/koin resmi. Semua klaim di `pump.fun` atau platform trading lainnya adalah **PENIPUAN**.
+>
+> * **DOMAIN RESMI:** Satu-satunya website resmi adalah **[picoclaw.io](https://picoclaw.io)**, dan website perusahaan adalah **[sipeed.com](https://sipeed.com)**
+> * **Peringatan:** Banyak domain `.ai/.org/.com/.net/...` yang didaftarkan oleh pihak ketiga.
+> * **Peringatan:** PicoClaw masih dalam tahap pengembangan awal dan mungkin memiliki masalah keamanan jaringan yang belum teratasi. Jangan deploy ke lingkungan produksi sebelum rilis v1.0.
+> * **Catatan:** PicoClaw baru-baru ini menggabungkan banyak PR, yang mungkin mengakibatkan penggunaan memori lebih besar (10–20MB) pada versi terbaru. Kami berencana untuk memprioritaskan optimasi sumber daya segera setelah fitur saat ini mencapai kondisi stabil.
+
+## 📢 Berita
+
+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 kini di **25K ⭐**!
+
+2026-03-09 🎉 **v0.2.1 — Update terbesar!** Dukungan protokol MCP, 4 channel baru (Matrix/IRC/WeCom/Discord Proxy), 3 provider baru (Kimi/Minimax/Avian), pipeline vision, penyimpanan memori JSONL, dan routing model.
+
+2026-02-28 📦 **v0.2.0** dirilis dengan dukungan Docker Compose dan launcher Web UI.
+
+2026-02-26 🎉 PicoClaw mencapai **20K bintang** hanya dalam 17 hari! Orkestrasi channel otomatis dan antarmuka kapabilitas diluncurkan.
+
+
+Berita lama...
+
+2026-02-16 🎉 PicoClaw mencapai 12K bintang dalam satu minggu! Peran maintainer komunitas dan [roadmap](ROADMAP.md) resmi diposting.
+
+2026-02-13 🎉 PicoClaw mencapai 5000 bintang dalam 4 hari! Roadmap Proyek dan pengaturan Grup Pengembang sedang berjalan.
+
+2026-02-09 🎉 **PicoClaw Diluncurkan!** Dibangun dalam 1 hari untuk menghadirkan AI Agent ke perangkat keras $10 dengan RAM <10MB. 🦐 PicoClaw, Ayo Berangkat!
+
+
+
+## ✨ Fitur
+
+🪶 **Super Ringan**: Penggunaan memori <10MB — 99% lebih kecil dari fungsionalitas inti OpenClaw.*
+
+💰 **Biaya Minimal**: Cukup efisien untuk berjalan di perangkat keras $10 — 98% lebih murah dari Mac mini.
+
+⚡️ **Secepat Kilat**: Waktu startup 400X lebih cepat, boot dalam <1 detik bahkan di prosesor single core 0,6GHz.
+
+🌍 **Portabilitas Sejati**: Satu binary mandiri untuk RISC-V, ARM, MIPS, dan x86, Satu Klik Langsung Jalan!
+
+🤖 **AI-Bootstrapped**: Implementasi Go-native secara otonom — 95% kode inti dihasilkan oleh Agent dengan penyempurnaan human-in-the-loop.
+
+🔌 **Dukungan MCP**: Integrasi [Model Context Protocol](https://modelcontextprotocol.io/) native — hubungkan server MCP mana pun untuk memperluas kapabilitas agent.
+
+👁️ **Pipeline Vision**: Kirim gambar dan file langsung ke agent — encoding base64 otomatis untuk LLM multimodal.
+
+🧠 **Routing Cerdas**: Routing model berbasis aturan — kueri sederhana diarahkan ke model ringan, menghemat biaya API.
+
+_*Versi terbaru mungkin menggunakan 10–20MB karena penggabungan fitur yang cepat. Optimasi sumber daya direncanakan. Perbandingan startup berdasarkan benchmark prosesor single-core 0,8GHz (lihat tabel di bawah)._
+
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
+| **Bahasa** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB*** |
+| **Startup**(0,8GHz core) | >500d | >30d | **<1d** |
+| **Biaya** | Mac Mini $599 | Kebanyakan Linux SBC ~$50 | **Semua Board Linux****Mulai dari $10** |
+
+
+
+## 🦾 Demonstrasi
+
+### 🛠️ Alur Kerja Asisten Standar
+
+
+
+ 🧩 Full-Stack Engineer |
+ 🗂️ Pencatatan & Manajemen Perencanaan |
+ 🔎 Pencarian Web & Pembelajaran |
+
+
+ 
|
+ 
|
+ 
|
+
+
+ | Develop • Deploy • Scale |
+ Jadwal • Otomasi • Memori |
+ Penemuan • Wawasan • Tren |
+
+
+
+### 📱 Jalankan di HP Android Lama
+
+Berikan kehidupan kedua untuk HP lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw. Panduan Cepat:
+
+1. **Instal [Termux](https://github.com/termux/termux-app)** (Unduh dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play).
+2. **Jalankan perintah**
+
+```bash
+# Unduh rilis terbaru dari https://github.com/sipeed/picoclaw/releases
+wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
+tar xzf picoclaw_Linux_arm64.tar.gz
+pkg install proot
+termux-chroot ./picoclaw onboard
+```
+
+Kemudian ikuti instruksi di bagian "Panduan Cepat" untuk menyelesaikan konfigurasi!
+
+
+
+### 🐜 Deploy Inovatif dengan Footprint Rendah
+
+PicoClaw dapat di-deploy di hampir semua perangkat Linux!
+
+- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versi E(Ethernet) atau W(WiFi6), untuk Home Assistant Minimal
+- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), atau $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) untuk Pemeliharaan Server Otomatis
+- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) atau $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) untuk Pemantauan Cerdas
+
+
+
+🌟 Lebih Banyak Kasus Deploy Menanti!
+
+## 📦 Instalasi
+
+### Instal dengan binary yang sudah dikompilasi
+
+Unduh binary untuk platform Anda dari halaman [Releases](https://github.com/sipeed/picoclaw/releases).
+
+### Instal dari source (fitur terbaru, disarankan untuk pengembangan)
+
+```bash
+git clone https://github.com/sipeed/picoclaw.git
+
+cd picoclaw
+make deps
+
+# Build, tidak perlu instal
+make build
+
+# Build untuk berbagai platform
+make build-all
+
+# Build untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
+make build-pi-zero
+
+# Build dan Instal
+make install
+```
+
+**Raspberry Pi Zero 2 W:** Gunakan binary yang sesuai dengan OS Anda: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Atau jalankan `make build-pi-zero` untuk build keduanya.
+
+## 📚 Dokumentasi
+
+Untuk panduan lengkap, lihat dokumen di bawah. README ini hanya berisi panduan cepat.
+
+| Topik | Deskripsi |
+|-------|-----------|
+| 🐳 [Docker & Panduan Cepat](docs/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent, konfigurasi Panduan Cepat |
+| 💬 [Aplikasi Chat](docs/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom, dan lainnya |
+| ⚙️ [Konfigurasi](docs/configuration.md) | Variabel environment, tata letak workspace, sumber skill, sandbox keamanan, heartbeat |
+| 🔌 [Provider & Model](docs/providers.md) | 20+ provider LLM, routing model, konfigurasi model_list, arsitektur provider |
+| 🔄 [Spawn & Tugas Async](docs/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async |
+| 🐛 [Pemecahan Masalah](docs/troubleshooting.md) | Masalah umum dan solusinya |
+| 🔧 [Konfigurasi Tools](docs/tools_configuration.md) | Aktifkan/nonaktifkan tool, kebijakan exec |
+
+##
Bergabung dengan Jaringan Sosial Agent
+
+Hubungkan Picoclaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan melalui CLI atau Aplikasi Chat terintegrasi.
+
+**Baca `https://clawdchat.ai/skill.md` dan ikuti instruksi untuk bergabung dengan [ClawdChat.ai](https://clawdchat.ai)**
+
+## 🖥️ Referensi CLI
+
+| Perintah | Deskripsi |
+| ------------------------- | -------------------------------- |
+| `picoclaw onboard` | Inisialisasi konfigurasi & workspace |
+| `picoclaw agent -m "..."` | Chat dengan agent |
+| `picoclaw agent` | Mode chat interaktif |
+| `picoclaw gateway` | Mulai gateway |
+| `picoclaw status` | Tampilkan status |
+| `picoclaw version` | Tampilkan info versi |
+| `picoclaw cron list` | Daftar semua tugas terjadwal |
+| `picoclaw cron add ...` | Tambah tugas terjadwal |
+| `picoclaw cron disable` | Nonaktifkan tugas terjadwal |
+| `picoclaw cron remove` | Hapus tugas terjadwal |
+| `picoclaw skills list` | Daftar skill yang terinstal |
+| `picoclaw skills install` | Instal skill |
+| `picoclaw migrate` | Migrasi data dari versi lama |
+| `picoclaw auth login` | Autentikasi dengan provider |
+
+### Tugas Terjadwal / Pengingat
+
+PicoClaw mendukung pengingat terjadwal dan tugas berulang melalui tool `cron`:
+
+* **Pengingat satu kali**: "Ingatkan saya dalam 10 menit" → terpicu sekali setelah 10 menit
+* **Tugas berulang**: "Ingatkan saya setiap 2 jam" → terpicu setiap 2 jam
+* **Ekspresi cron**: "Ingatkan saya jam 9 pagi setiap hari" → menggunakan ekspresi cron
+
+## 🤝 Kontribusi & Roadmap
+
+PR sangat diterima! Codebase sengaja dibuat kecil dan mudah dibaca. 🤗
+
+Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) lengkap kami.
+
+Grup pengembang sedang dibangun, bergabunglah setelah PR pertama Anda di-merge!
+
+Grup Pengguna:
+
+discord:
+
+
diff --git a/README.it.md b/README.it.md
index 1f5acadcf..27027d95f 100644
--- a/README.it.md
+++ b/README.it.md
@@ -18,7 +18,7 @@
-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) | **Italiano**
+[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [English](README.md)
diff --git a/README.ja.md b/README.ja.md
index 5cfd6359a..3c017aacd 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -18,7 +18,7 @@
-[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
+[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)
diff --git a/README.md b/README.md
index 2aa3b631f..d9785f200 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | **English**
+[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **English**
diff --git a/README.pt-br.md b/README.pt-br.md
index 04f7dae26..928e4778c 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -18,7 +18,7 @@
-[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
+[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)
diff --git a/README.vi.md b/README.vi.md
index 3832890ed..c7ad6b4be 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -18,7 +18,7 @@
-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [English](README.md)
+[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)
diff --git a/README.zh.md b/README.zh.md
index bbb8e8e4d..7bf936709 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -18,7 +18,7 @@
-**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
+**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)
From 05c65d2fe70c16c9671194606d939c5fdd621519 Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Thu, 19 Mar 2026 21:35:17 +0800
Subject: [PATCH 07/44] fix(provider): skip empty anthropic tool names (#1772)
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
---
pkg/providers/anthropic_messages/provider.go | 4 ++
.../anthropic_messages/provider_test.go | 45 +++++++++++++++++++
2 files changed, 49 insertions(+)
diff --git a/pkg/providers/anthropic_messages/provider.go b/pkg/providers/anthropic_messages/provider.go
index c201dfe00..2b19e941a 100644
--- a/pkg/providers/anthropic_messages/provider.go
+++ b/pkg/providers/anthropic_messages/provider.go
@@ -221,6 +221,10 @@ func buildRequestBody(
// Add tool_use blocks
for _, tc := range msg.ToolCalls {
+ if strings.TrimSpace(tc.Name) == "" {
+ continue
+ }
+
// Handle nil Arguments (GLM-4 may return null input)
input := tc.Arguments
if input == nil {
diff --git a/pkg/providers/anthropic_messages/provider_test.go b/pkg/providers/anthropic_messages/provider_test.go
index da4213e92..8eabc15fa 100644
--- a/pkg/providers/anthropic_messages/provider_test.go
+++ b/pkg/providers/anthropic_messages/provider_test.go
@@ -492,6 +492,20 @@ func TestBuildRequestBodyEdgeCases(t *testing.T) {
},
wantErr: false,
},
+ {
+ name: "skip tool calls with empty names",
+ messages: []Message{
+ {Role: "assistant", Content: "Calling tool", ToolCalls: []ToolCall{
+ {ID: "tool-empty", Name: "", Arguments: map[string]any{"ignored": true}},
+ {ID: "tool-valid", Name: "test_tool", Arguments: map[string]any{"arg": "value"}},
+ }},
+ },
+ model: "test-model",
+ options: map[string]any{
+ "max_tokens": 8192,
+ },
+ wantErr: false,
+ },
}
for _, tt := range tests {
@@ -513,6 +527,37 @@ func TestBuildRequestBodyEdgeCases(t *testing.T) {
if got["model"] != tt.model {
t.Errorf("model = %v, want %v", got["model"], tt.model)
}
+
+ if tt.name == "skip tool calls with empty names" {
+ messages, ok := got["messages"].([]any)
+ if !ok || len(messages) != 1 {
+ t.Fatalf("messages = %#v, want single assistant message", got["messages"])
+ }
+
+ assistantMsg, ok := messages[0].(map[string]any)
+ if !ok {
+ t.Fatalf("assistant message = %#v, want map", messages[0])
+ }
+
+ content, ok := assistantMsg["content"].([]any)
+ if !ok {
+ t.Fatalf("assistant content = %#v, want []any", assistantMsg["content"])
+ }
+ if len(content) != 2 {
+ t.Fatalf("assistant content length = %d, want 2", len(content))
+ }
+
+ toolUse, ok := content[1].(map[string]any)
+ if !ok {
+ t.Fatalf("tool_use block = %#v, want map", content[1])
+ }
+ if gotName := toolUse["name"]; gotName != "test_tool" {
+ t.Fatalf("tool_use name = %v, want %q", gotName, "test_tool")
+ }
+ if gotID := toolUse["id"]; gotID != "tool-valid" {
+ t.Fatalf("tool_use id = %v, want %q", gotID, "tool-valid")
+ }
+ }
})
}
}
From 276a0cb92cfaa886ac0332b533659365262472ae Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Thu, 19 Mar 2026 21:44:01 +0800
Subject: [PATCH 08/44] fix(agent): rebind provider after /switch model to
(#1769)
* fix(agent): rebind provider after model switch
* test(agent): deduplicate switch model mock servers
---------
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
---
pkg/agent/instance.go | 49 +------
pkg/agent/loop.go | 34 ++++-
pkg/agent/loop_test.go | 246 +++++++++++++++++++++++++++++++++-
pkg/agent/model_resolution.go | 97 ++++++++++++++
4 files changed, 371 insertions(+), 55 deletions(-)
create mode 100644 pkg/agent/model_resolution.go
diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go
index d2a4f81a4..355e78a33 100644
--- a/pkg/agent/instance.go
+++ b/pkg/agent/instance.go
@@ -152,59 +152,14 @@ func NewAgentInstance(
}
// Resolve fallback candidates
- modelCfg := providers.ModelConfig{
- Primary: model,
- Fallbacks: fallbacks,
- }
- resolveFromModelList := func(raw string) (string, bool) {
- ensureProtocol := func(model string) string {
- model = strings.TrimSpace(model)
- if model == "" {
- return ""
- }
- if strings.Contains(model, "/") {
- return model
- }
- return "openai/" + model
- }
-
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return "", false
- }
-
- if cfg != nil {
- if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
- return ensureProtocol(mc.Model), true
- }
-
- for i := range cfg.ModelList {
- fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
- if fullModel == "" {
- continue
- }
- if fullModel == raw {
- return ensureProtocol(fullModel), true
- }
- _, modelID := providers.ExtractProtocol(fullModel)
- if modelID == raw {
- return ensureProtocol(fullModel), true
- }
- }
- }
-
- return "", false
- }
-
- candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
+ candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks)
// Model routing setup: pre-resolve light model candidates at creation time
// to avoid repeated model_list lookups on every incoming message.
var router *routing.Router
var lightCandidates []providers.FallbackCandidate
if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" {
- lightModelCfg := providers.ModelConfig{Primary: rc.LightModel}
- resolved := providers.ResolveCandidatesWithLookup(lightModelCfg, defaults.Provider, resolveFromModelList)
+ resolved := resolveModelCandidates(cfg, defaults.Provider, rc.LightModel, nil)
if len(resolved) > 0 {
router = routing.New(routing.RouterConfig{
LightModel: rc.LightModel,
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index edb0994c2..aade18014 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -1477,7 +1477,7 @@ func (al *AgentLoop) selectCandidates(
history []providers.Message,
) (candidates []providers.FallbackCandidate, model string) {
if agent.Router == nil || len(agent.LightCandidates) == 0 {
- return agent.Candidates, agent.Model
+ return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model)
}
_, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model)
@@ -1488,7 +1488,7 @@ func (al *AgentLoop) selectCandidates(
"score": score,
"threshold": agent.Router.Threshold(),
})
- return agent.Candidates, agent.Model
+ return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model)
}
logger.InfoCF("agent", "Model routing: light model selected",
@@ -1498,7 +1498,7 @@ func (al *AgentLoop) selectCandidates(
"score": score,
"threshold": agent.Router.Threshold(),
})
- return agent.LightCandidates, agent.Router.LightModel()
+ return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel())
}
// maybeSummarize triggers summarization if the session history exceeds thresholds.
@@ -1961,11 +1961,37 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
}
if agent != nil {
rt.GetModelInfo = func() (string, string) {
- return agent.Model, cfg.Agents.Defaults.Provider
+ return agent.Model, resolvedCandidateProvider(agent.Candidates, cfg.Agents.Defaults.Provider)
}
rt.SwitchModel = func(value string) (string, error) {
+ value = strings.TrimSpace(value)
+ modelCfg, err := resolvedModelConfig(cfg, value, agent.Workspace)
+ if err != nil {
+ return "", err
+ }
+
+ nextProvider, _, err := providers.CreateProviderFromConfig(modelCfg)
+ if err != nil {
+ return "", fmt.Errorf("failed to initialize model %q: %w", value, err)
+ }
+
+ nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, modelCfg.Model, agent.Fallbacks)
+ if len(nextCandidates) == 0 {
+ return "", fmt.Errorf("model %q did not resolve to any provider candidates", value)
+ }
+
oldModel := agent.Model
+ oldProvider := agent.Provider
agent.Model = value
+ agent.Provider = nextProvider
+ agent.Candidates = nextCandidates
+ agent.ThinkingLevel = parseThinkingLevel(modelCfg.ThinkingLevel)
+
+ if oldProvider != nil && oldProvider != nextProvider {
+ if stateful, ok := oldProvider.(providers.StatefulProvider); ok {
+ stateful.Close()
+ }
+ }
return oldModel, nil
}
diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go
index 8432ccac4..b6b6c2c6c 100644
--- a/pkg/agent/loop_test.go
+++ b/pkg/agent/loop_test.go
@@ -2,7 +2,10 @@ package agent
import (
"context"
+ "encoding/json"
"fmt"
+ "net/http"
+ "net/http/httptest"
"os"
"path/filepath"
"slices"
@@ -444,6 +447,46 @@ type testHelper struct {
al *AgentLoop
}
+func newChatCompletionTestServer(
+ t *testing.T,
+ label string,
+ response string,
+ calls *int,
+ model *string,
+) *httptest.Server {
+ t.Helper()
+
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/chat/completions" {
+ t.Fatalf("%s server path = %q, want /chat/completions", label, r.URL.Path)
+ }
+ *calls = *calls + 1
+ defer r.Body.Close()
+
+ var req struct {
+ Model string `json:"model"`
+ }
+ decodeErr := json.NewDecoder(r.Body).Decode(&req)
+ if decodeErr != nil {
+ t.Fatalf("decode %s request: %v", label, decodeErr)
+ }
+ *model = req.Model
+
+ w.Header().Set("Content-Type", "application/json")
+ encodeErr := json.NewEncoder(w).Encode(map[string]any{
+ "choices": []map[string]any{
+ {
+ "message": map[string]any{"content": response},
+ "finish_reason": "stop",
+ },
+ },
+ })
+ if encodeErr != nil {
+ t.Fatalf("encode %s response: %v", label, encodeErr)
+ }
+ }))
+}
+
func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, msg bus.InboundMessage) string {
// Use a short timeout to avoid hanging
timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout)
@@ -605,11 +648,25 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Provider: "openai",
- Model: "before-switch",
+ Model: "local",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
+ ModelList: []config.ModelConfig{
+ {
+ ModelName: "local",
+ Model: "openai/local-model",
+ APIKey: "test-key",
+ APIBase: "https://local.example.invalid/v1",
+ },
+ {
+ ModelName: "deepseek",
+ Model: "openrouter/deepseek/deepseek-v3.2",
+ APIKey: "test-key",
+ APIBase: "https://openrouter.ai/api/v1",
+ },
+ },
}
msgBus := bus.NewMessageBus()
@@ -621,13 +678,13 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
Channel: "telegram",
SenderID: "user1",
ChatID: "chat1",
- Content: "/switch model to after-switch",
+ Content: "/switch model to deepseek",
Peer: bus.Peer{
Kind: "direct",
ID: "user1",
},
})
- if !strings.Contains(switchResp, "Switched model from before-switch to after-switch") {
+ if !strings.Contains(switchResp, "Switched model from local to deepseek") {
t.Fatalf("unexpected /switch reply: %q", switchResp)
}
@@ -641,7 +698,7 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
ID: "user1",
},
})
- if !strings.Contains(showResp, "Current Model: after-switch (Provider: openai)") {
+ if !strings.Contains(showResp, "Current Model: deepseek (Provider: openrouter)") {
t.Fatalf("unexpected /show model reply after switch: %q", showResp)
}
@@ -650,6 +707,187 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
}
}
+func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "agent-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: tmpDir,
+ Provider: "openai",
+ Model: "local",
+ MaxTokens: 4096,
+ MaxToolIterations: 10,
+ },
+ },
+ ModelList: []config.ModelConfig{
+ {
+ ModelName: "local",
+ Model: "openai/local-model",
+ APIKey: "test-key",
+ APIBase: "https://local.example.invalid/v1",
+ },
+ },
+ }
+
+ msgBus := bus.NewMessageBus()
+ provider := &countingMockProvider{response: "LLM reply"}
+ al := NewAgentLoop(cfg, msgBus, provider)
+ helper := testHelper{al: al}
+
+ switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
+ Channel: "telegram",
+ SenderID: "user1",
+ ChatID: "chat1",
+ Content: "/switch model to missing",
+ Peer: bus.Peer{
+ Kind: "direct",
+ ID: "user1",
+ },
+ })
+ if switchResp != `model "missing" not found in model_list or providers` {
+ t.Fatalf("unexpected /switch error reply: %q", switchResp)
+ }
+
+ showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
+ Channel: "telegram",
+ SenderID: "user1",
+ ChatID: "chat1",
+ Content: "/show model",
+ Peer: bus.Peer{
+ Kind: "direct",
+ ID: "user1",
+ },
+ })
+ if !strings.Contains(showResp, "Current Model: local (Provider: openai)") {
+ t.Fatalf("unexpected /show model reply after rejected switch: %q", showResp)
+ }
+
+ if provider.calls != 0 {
+ t.Fatalf("LLM should not be called for rejected /switch and /show, calls=%d", provider.calls)
+ }
+}
+
+func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "agent-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ localCalls := 0
+ localModel := ""
+ localServer := newChatCompletionTestServer(t, "local", "local reply", &localCalls, &localModel)
+ defer localServer.Close()
+
+ remoteCalls := 0
+ remoteModel := ""
+ remoteServer := newChatCompletionTestServer(t, "remote", "remote reply", &remoteCalls, &remoteModel)
+ defer remoteServer.Close()
+
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: tmpDir,
+ Provider: "openai",
+ Model: "local",
+ MaxTokens: 4096,
+ MaxToolIterations: 10,
+ },
+ },
+ ModelList: []config.ModelConfig{
+ {
+ ModelName: "local",
+ Model: "openai/Qwen3.5-35B-A3B",
+ APIKey: "local-key",
+ APIBase: localServer.URL,
+ },
+ {
+ ModelName: "deepseek",
+ Model: "openrouter/deepseek/deepseek-v3.2",
+ APIKey: "remote-key",
+ APIBase: remoteServer.URL,
+ },
+ },
+ }
+
+ msgBus := bus.NewMessageBus()
+ provider, _, err := providers.CreateProvider(cfg)
+ if err != nil {
+ t.Fatalf("CreateProvider() error = %v", err)
+ }
+ al := NewAgentLoop(cfg, msgBus, provider)
+ helper := testHelper{al: al}
+
+ firstResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
+ Channel: "telegram",
+ SenderID: "user1",
+ ChatID: "chat1",
+ Content: "hello before switch",
+ Peer: bus.Peer{
+ Kind: "direct",
+ ID: "user1",
+ },
+ })
+ if firstResp != "local reply" {
+ t.Fatalf("unexpected response before switch: %q", firstResp)
+ }
+ if localCalls != 1 {
+ t.Fatalf("local calls before switch = %d, want 1", localCalls)
+ }
+ if remoteCalls != 0 {
+ t.Fatalf("remote calls before switch = %d, want 0", remoteCalls)
+ }
+ if localModel != "Qwen3.5-35B-A3B" {
+ t.Fatalf("local model before switch = %q, want %q", localModel, "Qwen3.5-35B-A3B")
+ }
+
+ switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
+ Channel: "telegram",
+ SenderID: "user1",
+ ChatID: "chat1",
+ Content: "/switch model to deepseek",
+ Peer: bus.Peer{
+ Kind: "direct",
+ ID: "user1",
+ },
+ })
+ if !strings.Contains(switchResp, "Switched model from local to deepseek") {
+ t.Fatalf("unexpected /switch reply: %q", switchResp)
+ }
+
+ secondResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
+ Channel: "telegram",
+ SenderID: "user1",
+ ChatID: "chat1",
+ Content: "hello after switch",
+ Peer: bus.Peer{
+ Kind: "direct",
+ ID: "user1",
+ },
+ })
+ if secondResp != "remote reply" {
+ t.Fatalf("unexpected response after switch: %q", secondResp)
+ }
+ if localCalls != 1 {
+ t.Fatalf("local calls after switch = %d, want 1", localCalls)
+ }
+ if remoteCalls != 1 {
+ t.Fatalf("remote calls after switch = %d, want 1", remoteCalls)
+ }
+ if remoteModel != "deepseek-v3.2" {
+ t.Fatalf(
+ "remote model after switch = %q, want %q",
+ remoteModel,
+ "deepseek-v3.2",
+ )
+ }
+}
+
// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound
func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
diff --git a/pkg/agent/model_resolution.go b/pkg/agent/model_resolution.go
new file mode 100644
index 000000000..140cff718
--- /dev/null
+++ b/pkg/agent/model_resolution.go
@@ -0,0 +1,97 @@
+package agent
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+func buildModelListResolver(cfg *config.Config) func(raw string) (string, bool) {
+ ensureProtocol := func(model string) string {
+ model = strings.TrimSpace(model)
+ if model == "" {
+ return ""
+ }
+ if strings.Contains(model, "/") {
+ return model
+ }
+ return "openai/" + model
+ }
+
+ return func(raw string) (string, bool) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" || cfg == nil {
+ return "", false
+ }
+
+ if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
+ return ensureProtocol(mc.Model), true
+ }
+
+ for i := range cfg.ModelList {
+ fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
+ if fullModel == "" {
+ continue
+ }
+ if fullModel == raw {
+ return ensureProtocol(fullModel), true
+ }
+ _, modelID := providers.ExtractProtocol(fullModel)
+ if modelID == raw {
+ return ensureProtocol(fullModel), true
+ }
+ }
+
+ return "", false
+ }
+}
+
+func resolveModelCandidates(
+ cfg *config.Config,
+ defaultProvider string,
+ primary string,
+ fallbacks []string,
+) []providers.FallbackCandidate {
+ return providers.ResolveCandidatesWithLookup(
+ providers.ModelConfig{
+ Primary: primary,
+ Fallbacks: fallbacks,
+ },
+ defaultProvider,
+ buildModelListResolver(cfg),
+ )
+}
+
+func resolvedCandidateModel(candidates []providers.FallbackCandidate, fallback string) string {
+ if len(candidates) > 0 && strings.TrimSpace(candidates[0].Model) != "" {
+ return candidates[0].Model
+ }
+ return fallback
+}
+
+func resolvedCandidateProvider(candidates []providers.FallbackCandidate, fallback string) string {
+ if len(candidates) > 0 && strings.TrimSpace(candidates[0].Provider) != "" {
+ return candidates[0].Provider
+ }
+ return fallback
+}
+
+func resolvedModelConfig(cfg *config.Config, modelName, workspace string) (*config.ModelConfig, error) {
+ if cfg == nil {
+ return nil, fmt.Errorf("config is nil")
+ }
+
+ modelCfg, err := cfg.GetModelConfig(strings.TrimSpace(modelName))
+ if err != nil {
+ return nil, err
+ }
+
+ clone := *modelCfg
+ if clone.Workspace == "" {
+ clone.Workspace = workspace
+ }
+
+ return &clone, nil
+}
From 9a3ca8e54d4a224a2a782c6d4855c42bfe90a353 Mon Sep 17 00:00:00 2001
From: Adi Susilayasa <71677862+adisusilayasa@users.noreply.github.com>
Date: Thu, 19 Mar 2026 22:07:30 +0800
Subject: [PATCH 09/44] feat(provider): add Alibaba Coding Plan and regional
Qwen endpoints (#1748)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(provider): add Alibaba Coding Plan and regional Qwen endpoints
- Add Alibaba Coding Plan provider with OpenAI-compatible endpoint
(https://coding-intl.dashscope.aliyuncs.com/v1)
- Add Coding Plan Anthropic-compatible endpoint
(https://coding-intl.dashscope.aliyuncs.com/apps/anthropic)
- Add regional Qwen endpoints (qwen-intl, qwen-us)
- Add provider aliases: coding-plan, alibaba-coding, qwen-coding
- Normalize provider names for coding-plan variants
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
* fix(provider): add reviewer-requested fixes for Alibaba Coding Plan
- Add qwen-international, dashscope-intl, dashscope-us aliases to switch case
- Add coding-plan-anthropic case with anthropicmessages.NewProviderWithTimeout
- Add alibaba-coding-anthropic -> coding-plan-anthropic normalization
- Add qwen-international -> qwen-intl and dashscope-us -> qwen-us normalization
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
* test(provider): add tests for Alibaba Coding Plan protocol aliases
- Add tests for qwen-international, dashscope-intl, dashscope-us aliases
- Add tests for coding-plan-anthropic and alibaba-coding-anthropic
- Add getDefaultAPIBase tests for all new aliases
- Add normalization tests for new provider aliases
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---------
Co-authored-by: Claude
---
pkg/providers/factory_provider.go | 28 +++++-
pkg/providers/factory_provider_test.go | 131 +++++++++++++++++++++++++
pkg/providers/model_ref.go | 8 ++
pkg/providers/model_ref_test.go | 8 ++
4 files changed, 173 insertions(+), 2 deletions(-)
diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go
index dbb5db5cb..a7fef8f5b 100644
--- a/pkg/providers/factory_provider.go
+++ b/pkg/providers/factory_provider.go
@@ -115,8 +115,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
- "vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian",
- "minimax", "longcat", "modelscope", "novita":
+ "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":
// All other OpenAI-compatible HTTP providers
if cfg.APIKey == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
@@ -173,6 +174,21 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.RequestTimeout,
), modelID, nil
+ case "coding-plan-anthropic", "alibaba-coding-anthropic":
+ // Alibaba Coding Plan with Anthropic-compatible API
+ apiBase := cfg.APIBase
+ if apiBase == "" {
+ apiBase = getDefaultAPIBase(protocol)
+ }
+ if cfg.APIKey == "" {
+ return nil, "", fmt.Errorf("api_key is required for %q protocol (model: %s)", protocol, cfg.Model)
+ }
+ return anthropicmessages.NewProviderWithTimeout(
+ cfg.APIKey,
+ apiBase,
+ cfg.RequestTimeout,
+ ), modelID, nil
+
case "antigravity":
return NewAntigravityProvider(), modelID, nil
@@ -245,6 +261,14 @@ func getDefaultAPIBase(protocol string) string {
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":
diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go
index c7629ad9d..8b9ddeecd 100644
--- a/pkg/providers/factory_provider_test.go
+++ b/pkg/providers/factory_provider_test.go
@@ -472,3 +472,134 @@ func TestCreateProviderFromConfig_AzureMissingAPIBase(t *testing.T) {
t.Fatal("CreateProviderFromConfig() expected error for missing API base")
}
}
+
+func TestCreateProviderFromConfig_QwenInternationalAlias(t *testing.T) {
+ tests := []struct {
+ name string
+ protocol string
+ }{
+ {"qwen-international", "qwen-international"},
+ {"dashscope-intl", "dashscope-intl"},
+ {"qwen-intl", "qwen-intl"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-" + tt.protocol,
+ Model: tt.protocol + "/qwen-max",
+ APIKey: "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 != "qwen-max" {
+ t.Errorf("modelID = %q, want %q", modelID, "qwen-max")
+ }
+ if _, ok := provider.(*HTTPProvider); !ok {
+ t.Fatalf("expected *HTTPProvider, got %T", provider)
+ }
+ })
+ }
+}
+
+func TestCreateProviderFromConfig_QwenUSAlias(t *testing.T) {
+ tests := []struct {
+ name string
+ protocol string
+ }{
+ {"qwen-us", "qwen-us"},
+ {"dashscope-us", "dashscope-us"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-" + tt.protocol,
+ Model: tt.protocol + "/qwen-max",
+ APIKey: "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 != "qwen-max" {
+ t.Errorf("modelID = %q, want %q", modelID, "qwen-max")
+ }
+ if _, ok := provider.(*HTTPProvider); !ok {
+ t.Fatalf("expected *HTTPProvider, got %T", provider)
+ }
+ })
+ }
+}
+
+func TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) {
+ tests := []struct {
+ name string
+ protocol string
+ }{
+ {"coding-plan-anthropic", "coding-plan-anthropic"},
+ {"alibaba-coding-anthropic", "alibaba-coding-anthropic"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-" + tt.protocol,
+ Model: tt.protocol + "/claude-sonnet-4-20250514",
+ APIKey: "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 != "claude-sonnet-4-20250514" {
+ t.Errorf("modelID = %q, want %q", modelID, "claude-sonnet-4-20250514")
+ }
+ // coding-plan-anthropic uses Anthropic Messages provider
+ // Verify it's the anthropic messages provider by checking interface
+ var _ LLMProvider = provider
+ })
+ }
+}
+
+func TestGetDefaultAPIBase_CodingPlanAnthropic(t *testing.T) {
+ expectedURL := "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
+ if got := getDefaultAPIBase("coding-plan-anthropic"); got != expectedURL {
+ t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "coding-plan-anthropic", got, expectedURL)
+ }
+ if got := getDefaultAPIBase("alibaba-coding-anthropic"); got != expectedURL {
+ t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "alibaba-coding-anthropic", got, expectedURL)
+ }
+}
+
+func TestGetDefaultAPIBase_QwenIntlAliases(t *testing.T) {
+ expectedURL := "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
+ for _, protocol := range []string{"qwen-intl", "qwen-international", "dashscope-intl"} {
+ if got := getDefaultAPIBase(protocol); got != expectedURL {
+ t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", protocol, got, expectedURL)
+ }
+ }
+}
+
+func TestGetDefaultAPIBase_QwenUSAliases(t *testing.T) {
+ expectedURL := "https://dashscope-us.aliyuncs.com/compatible-mode/v1"
+ for _, protocol := range []string{"qwen-us", "dashscope-us"} {
+ if got := getDefaultAPIBase(protocol); got != expectedURL {
+ t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", protocol, got, expectedURL)
+ }
+ }
+}
diff --git a/pkg/providers/model_ref.go b/pkg/providers/model_ref.go
index 0d1b02d16..be9f63bc6 100644
--- a/pkg/providers/model_ref.go
+++ b/pkg/providers/model_ref.go
@@ -53,6 +53,14 @@ func NormalizeProvider(provider string) string {
return "zhipu"
case "google":
return "gemini"
+ case "alibaba-coding", "qwen-coding":
+ return "coding-plan"
+ case "alibaba-coding-anthropic":
+ return "coding-plan-anthropic"
+ case "qwen-international", "dashscope-intl":
+ return "qwen-intl"
+ case "dashscope-us":
+ return "qwen-us"
}
return p
diff --git a/pkg/providers/model_ref_test.go b/pkg/providers/model_ref_test.go
index 6dd25167f..040c511ba 100644
--- a/pkg/providers/model_ref_test.go
+++ b/pkg/providers/model_ref_test.go
@@ -73,6 +73,14 @@ func TestNormalizeProvider(t *testing.T) {
{"glm", "zhipu"},
{"google", "gemini"},
{"groq", "groq"},
+ // Alibaba Coding Plan aliases
+ {"alibaba-coding", "coding-plan"},
+ {"qwen-coding", "coding-plan"},
+ {"alibaba-coding-anthropic", "coding-plan-anthropic"},
+ // Qwen international aliases
+ {"qwen-international", "qwen-intl"},
+ {"dashscope-intl", "qwen-intl"},
+ {"dashscope-us", "qwen-us"},
{"", ""},
}
From d715ff5031f64627fcfad0682fb7cfc929da5b32 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=BE=8E=E9=9B=BB=E7=90=83?=
Date: Thu, 19 Mar 2026 23:30:25 +0800
Subject: [PATCH 10/44] docs: expand bindings guide with recipes and
troubleshooting (#1788)
---
docs/configuration.md | 129 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 129 insertions(+)
diff --git a/docs/configuration.md b/docs/configuration.md
index 202ad4f59..268de9135 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -71,6 +71,135 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup.
- Unknown slash command (for example `/foo`) passes through to normal LLM processing.
- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing.
+
+### Agent Bindings (Route messages to specific agents)
+
+Use `bindings` in `config.json` to route incoming messages to different agents by channel/account/context.
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model_name": "gpt-4o-mini"
+ },
+ "list": [
+ { "id": "main", "default": true, "name": "Main Assistant" },
+ { "id": "support", "name": "Support Assistant" },
+ { "id": "sales", "name": "Sales Assistant" }
+ ]
+ },
+ "bindings": [
+ {
+ "agent_id": "support",
+ "match": {
+ "channel": "telegram",
+ "account_id": "*",
+ "peer": { "kind": "direct", "id": "user123" }
+ }
+ },
+ {
+ "agent_id": "sales",
+ "match": {
+ "channel": "discord",
+ "account_id": "my-discord-bot",
+ "guild_id": "987654321"
+ }
+ }
+ ]
+}
+```
+
+#### `bindings` fields
+
+| Field | Required | Description |
+|-------|----------|-------------|
+| `agent_id` | Yes | Target agent id in `agents.list` |
+| `match.channel` | Yes | Channel name (e.g. `telegram`, `discord`) |
+| `match.account_id` | No | Channel account filter. Use `"*"` for all accounts of that channel. If omitted, only default account is matched |
+| `match.peer.kind` + `match.peer.id` | No | Exact peer match (e.g. direct chat / topic / group id) |
+| `match.guild_id` | No | Guild/server-level match |
+| `match.team_id` | No | Team/workspace-level match |
+
+#### Matching priority
+
+When multiple bindings exist, PicoClaw resolves in this order:
+
+1. `peer`
+2. `parent_peer` (for thread/topic parent contexts)
+3. `guild_id`
+4. `team_id`
+5. `account_id` (non-wildcard)
+6. channel wildcard (`account_id: "*"`)
+7. default agent
+
+If a binding points to a missing `agent_id`, PicoClaw falls back to the default agent.
+
+#### How matching works (step-by-step)
+
+1. PicoClaw first filters bindings by `match.channel` (must equal current channel).
+2. It then filters by `match.account_id`:
+ - omitted: match only the channel's default account
+ - `"*"`: match all accounts on this channel
+ - explicit value: exact account id match (case-insensitive)
+3. From the remaining candidates, it applies the priority chain above and stops at the first hit.
+
+In other words: **channel + account form the candidate set; peer/guild/team then decide final winner**.
+
+#### Common recipes
+
+**1) Route one specific DM user to a specialist agent**
+
+```json
+{
+ "agent_id": "support",
+ "match": {
+ "channel": "telegram",
+ "account_id": "*",
+ "peer": { "kind": "direct", "id": "user123" }
+ }
+}
+```
+
+**2) Route one Discord server (guild) to a dedicated agent**
+
+```json
+{
+ "agent_id": "sales",
+ "match": {
+ "channel": "discord",
+ "account_id": "my-discord-bot",
+ "guild_id": "987654321"
+ }
+}
+```
+
+**3) Route all remaining traffic of a channel to a fallback agent**
+
+```json
+{
+ "agent_id": "main",
+ "match": {
+ "channel": "discord",
+ "account_id": "*"
+ }
+}
+```
+
+#### Authoring guidelines (important)
+
+- Keep exactly one clear default agent in `agents.list` (`"default": true`).
+- Put specific rules (`peer`, `guild_id`, `team_id`) and broad rules (`account_id: "*"` only) together safely; priority already guarantees specific rules win.
+- Avoid duplicate rules with the same specificity and match values. If duplicates exist, the first matching entry in the config array wins.
+- Ensure every `agent_id` exists in `agents.list`; unknown IDs silently fall back to default.
+
+#### Troubleshooting checklist
+
+- **Rule not taking effect?** Check `match.channel` spelling first (must be exact).
+- **Expected account-specific routing but still using default?** Verify `match.account_id` equals actual runtime account id.
+- **Wildcard catches too much traffic?** Add more specific `peer/guild/team` rules for critical paths.
+- **Unexpected default fallback?** Confirm `agent_id` exists and is not misspelled.
+
### 🔒 Security Sandbox
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
From e3cc5b10009e2d950924807bdf73e281d39151cb Mon Sep 17 00:00:00 2001
From: opcache <39149378+opcache@users.noreply.github.com>
Date: Thu, 19 Mar 2026 23:46:17 +0800
Subject: [PATCH 11/44] Fix the limitation on the number of tables in cards
caused by Feishu (#1736)
* Fix the limitation on the number of tables in cards caused by Feishu
* Only match the error code 11310
---
pkg/channels/feishu/feishu_64.go | 64 +++++++++++++++++++++++++++++++-
1 file changed, 62 insertions(+), 2 deletions(-)
diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go
index 3aea67b12..0341efc70 100644
--- a/pkg/channels/feishu/feishu_64.go
+++ b/pkg/channels/feishu/feishu_64.go
@@ -11,6 +11,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "strings"
"sync"
"sync/atomic"
@@ -129,6 +130,7 @@ 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 {
if !c.IsRunning() {
return channels.ErrNotRunning
@@ -141,9 +143,38 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
// Build interactive card with markdown content
cardContent, err := buildMarkdownCard(msg.Content)
if err != nil {
- return fmt.Errorf("feishu send: card build failed: %w", err)
+ // If card build fails, fall back to plain text
+ return c.sendText(ctx, msg.ChatID, msg.Content)
}
- return c.sendCard(ctx, msg.ChatID, cardContent)
+
+ // First attempt: try sending as interactive card
+ err = c.sendCard(ctx, msg.ChatID, cardContent)
+ if err == nil {
+ return nil
+ }
+
+ // Check if error is due to card table limit (error code 11310)
+ // See: https://open.feishu.cn/document/server-docs/im-api/message-content-description/create_json
+ errMsg := err.Error()
+ isCardLimitError := strings.Contains(errMsg, "11310")
+
+ if isCardLimitError {
+ logger.WarnCF("feishu", "Card send failed (table limit), falling back to text message", map[string]any{
+ "chat_id": msg.ChatID,
+ "error": errMsg,
+ })
+
+ // Second attempt: fall back to plain text message
+ textErr := c.sendText(ctx, msg.ChatID, msg.Content)
+ if textErr == nil {
+ return nil
+ }
+ // If text also fails, return the text error
+ return textErr
+ }
+
+ // For other errors, return the original card error
+ return err
}
// EditMessage implements channels.MessageEditor.
@@ -738,6 +769,35 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string
return nil
}
+// sendText sends a plain text message to a chat (fallback when card fails).
+func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) error {
+ content, _ := json.Marshal(map[string]string{"text": text})
+
+ req := larkim.NewCreateMessageReqBuilder().
+ ReceiveIdType(larkim.ReceiveIdTypeChatId).
+ Body(larkim.NewCreateMessageReqBodyBuilder().
+ ReceiveId(chatID).
+ MsgType(larkim.MsgTypeText).
+ Content(string(content)).
+ Build()).
+ Build()
+
+ resp, err := c.client.Im.V1.Message.Create(ctx, req)
+ if err != nil {
+ return fmt.Errorf("feishu send text: %w", channels.ErrTemporary)
+ }
+
+ if !resp.Success() {
+ return fmt.Errorf("feishu text api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary)
+ }
+
+ logger.DebugCF("feishu", "Feishu text message sent (fallback)", map[string]any{
+ "chat_id": chatID,
+ })
+
+ return nil
+}
+
// sendImage uploads an image and sends it as a message.
func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.File) error {
// Upload image to get image_key
From 75d86721a3cffabd1721537134740c0e2a3402db Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=BE=8E=E9=9B=BB=E7=90=83?=
Date: Fri, 20 Mar 2026 00:23:40 +0800
Subject: [PATCH 12/44] Feat/wecom aibot processing message config (#1785)
* feat(wecom_aibot): make processing message configurable
* docs(wecom): document ai bot processing message
* test(wecom_aibot): adapt webhook tests to channel interface
* fix: lint err
---
docs/channels/wecom/wecom_aibot/README.zh.md | 136 +++++++++++++++++--
docs/chat-apps.md | 3 +-
docs/fr/chat-apps.md | 3 +-
docs/ja/chat-apps.md | 3 +-
docs/vi/chat-apps.md | 3 +-
docs/zh/chat-apps.md | 3 +-
pkg/channels/wecom/aibot.go | 5 +-
pkg/channels/wecom/aibot_test.go | 103 ++++++++++++++
pkg/config/config.go | 28 ++--
pkg/config/defaults.go | 17 +--
10 files changed, 268 insertions(+), 36 deletions(-)
diff --git a/docs/channels/wecom/wecom_aibot/README.zh.md b/docs/channels/wecom/wecom_aibot/README.zh.md
index de4fba445..48a151a25 100644
--- a/docs/channels/wecom/wecom_aibot/README.zh.md
+++ b/docs/channels/wecom/wecom_aibot/README.zh.md
@@ -1,6 +1,9 @@
# 企业微信智能机器人 (AI Bot)
-企业微信智能机器人(AI Bot)是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议。
+企业微信智能机器人(AI Bot)是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议。PicoClaw 当前同时支持两种接入模式:
+
+- WebSocket 长连接模式:使用 `bot_id` + `secret`,优先级更高,推荐使用
+- Webhook 短连接模式:使用 `token` + `encoding_aes_key`,兼容传统回调,并支持超时后通过 `response_url` 主动推送最终回复
## 与其他 WeCom 通道的对比
@@ -14,6 +17,8 @@
## 配置
+### WebSocket 长连接模式(推荐)
+
```json
{
"channels": {
@@ -29,22 +34,113 @@
}
```
-| 字段 | 类型 | 必填 | 描述 |
-| ---------------- | ------ | ---- | -------------------------------------------------- |
-| bot_id | string | 是 | AI Bot 的唯一标识,在 AI Bot 管理页面配置 |
-| secret | string | 是 | AI Bot 的密钥,在 AI Bot 管理页面配置 |
-| allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 |
-| welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 |
-| reply_timeout | int | 否 | 回复超时时间(秒,默认:5) |
-| max_steps | int | 否 | Agent 最大执行步骤数(默认:10) |
+### Webhook 短连接模式
+
+```json
+{
+ "channels": {
+ "wecom_aibot": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
+ "webhook_path": "/webhook/wecom-aibot",
+ "allow_from": [],
+ "welcome_message": "你好!有什么可以帮助你的吗?",
+ "processing_message": "⏳ Processing, please wait. The results will be sent shortly.",
+ "max_steps": 10
+ }
+ }
+}
+```
+
+### WebSocket 模式字段
+
+| 字段 | 类型 | 必填 | 描述 |
+|--------|--------|------|--------------------------------------------|
+| bot_id | string | 是 | AI Bot 的唯一标识,在 AI Bot 管理页面配置 |
+| secret | string | 是 | AI Bot 的密钥,在 AI Bot 管理页面配置 |
+
+### Webhook 模式字段
+
+| 字段 | 类型 | 必填 | 描述 |
+|------------------|--------|------|----------------------------------------------|
+| token | string | 是 | 回调验证令牌,在 AI Bot 管理页面配置 |
+| encoding_aes_key | string | 是 | 43 字符 AES 密钥,在 AI Bot 管理页面随机生成 |
+| webhook_path | string | 否 | Webhook 路径,默认 `/webhook/wecom-aibot` |
+| processing_message | string | 否 | 流式超时后返回给用户的提示语 |
+
+### 通用字段
+
+| 字段 | 类型 | 必填 | 描述 |
+|-----------------|--------|------|------------------------------------------|
+| allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 |
+| welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 |
+| reply_timeout | int | 否 | 回复超时时间(秒,默认:5) |
+| max_steps | int | 否 | Agent 最大执行步骤数(默认:10) |
+
+## 模式选择
+
+- 当 `bot_id` 和 `secret` 同时存在时,PicoClaw 会优先使用 WebSocket 长连接模式
+- 否则,当 `token` 和 `encoding_aes_key` 同时存在时,PicoClaw 会使用 Webhook 短连接模式
## 设置流程
+### WebSocket 长连接模式
+
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)
2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot
-3. 在 AI Bot 配置页面,配置Bot的名称、头像等信息,获取 `Bot ID` 和 `Secret`
+3. 在 AI Bot 配置页面,配置 Bot 的名称、头像等信息,获取 `Bot ID` 和 `Secret`
4. 在 PicoClaw 配置文件中添加上述配置,重启 PicoClaw
+### Webhook 短连接模式
+
+1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)
+2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot
+3. 在 AI Bot 配置页面,填写"消息接收"信息:
+ - **URL**:`http://:18791/webhook/wecom-aibot`
+ - **Token**:随机生成或自定义
+ - **EncodingAESKey**:点击"随机生成",得到 43 字符密钥
+4. 将 Token 和 EncodingAESKey 填入 PicoClaw 配置文件,启动服务后回到管理后台保存
+
+> [!TIP]
+> 服务器需要能被企业微信服务器访问。如在内网或本地开发,可使用 [ngrok](https://ngrok.com) 或 frp 做内网穿透。
+
+## Webhook 模式的流式响应协议
+
+Webhook 模式使用"流式拉取"协议,区别于普通 Webhook 的一次性回复:
+
+```
+用户发消息
+ │
+ ▼
+PicoClaw 立即返回 {finish: false}(Agent 开始处理)
+ │
+ ▼
+企业微信每隔约 1 秒拉取一次 {msgtype: "stream", stream: {id: "..."}}
+ │
+ ├─ Agent 未完成 → 返回 {finish: false}(继续等待)
+ │
+ └─ Agent 完成 → 返回 {finish: true, content: "回答内容"}
+```
+
+**超时处理**(任务超过约 30 秒):
+
+若 Agent 处理时间超过轮询窗口,PicoClaw 会:
+
+1. 立即关闭流,向用户显示 `processing_message` 提示语
+2. Agent 继续在后台运行
+3. Agent 完成后,通过消息中携带的 `response_url` 将最终回复主动推送给用户
+
+> `response_url` 由企业微信颁发,有效期 1 小时,只可使用一次,无需加密,直接 POST markdown 消息体即可。
+
+## 超时提示语
+
+配置 `processing_message` 后,当 Webhook 模式的流式轮询超时并切换到 `response_url` 主动推送模式时,PicoClaw 会先返回这段提示语来结束当前流。
+
+```json
+"processing_message": "⏳ Processing, please wait. The results will be sent shortly."
+```
+
## 欢迎语
配置 `welcome_message` 后,当用户打开与 AI Bot 的聊天窗口时(`enter_chat` 事件),PicoClaw 会自动回复该欢迎语。留空则静默忽略。
@@ -55,12 +151,32 @@
## 常见问题
+### WebSocket 模式无法连接
+
+- 检查 `bot_id` 和 `secret` 是否填写正确
+- 查看日志中是否有 WebSocket 连接或鉴权失败信息
+- 确认服务器可以访问企业微信长连接接口
+
+### 回调 URL 验证失败
+
+- 确认 `token` 与 `encoding_aes_key` 填写正确
+- 确认服务器防火墙已开放对应端口
+- 检查 PicoClaw 日志是否收到了来自企业微信的验证请求
+
### 消息没有回复
- 检查 `allow_from` 是否意外限制了发送者
- 查看日志中是否出现 `context canceled` 或 Agent 错误
- 确认 Agent 配置(`model_name` 等)正确
+### 超长任务没有收到最终推送
+
+- 确认消息回调中携带了 `response_url`
+- 确认服务器能主动访问外网
+- 查看日志关键词 `response_url mode` 和 `Sending reply via response_url`
+
## 参考文档
- [企业微信 AI Bot 接入文档](https://developer.work.weixin.qq.com/document/path/101463)
+- [流式响应协议说明](https://developer.work.weixin.qq.com/document/path/100719)
+- [response_url 主动回复](https://developer.work.weixin.qq.com/document/path/101138)
diff --git a/docs/chat-apps.md b/docs/chat-apps.md
index 05afc7f33..66aa7ea53 100644
--- a/docs/chat-apps.md
+++ b/docs/chat-apps.md
@@ -414,7 +414,8 @@ picoclaw gateway
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
- "welcome_message": "Hello! How can I help you?"
+ "welcome_message": "Hello! How can I help you?",
+ "processing_message": "⏳ Processing, please wait. The results will be sent shortly."
}
}
}
diff --git a/docs/fr/chat-apps.md b/docs/fr/chat-apps.md
index 03bb6e17b..39026e0df 100644
--- a/docs/fr/chat-apps.md
+++ b/docs/fr/chat-apps.md
@@ -410,7 +410,8 @@ picoclaw gateway
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
- "welcome_message": "Hello! How can I help you?"
+ "welcome_message": "Hello! How can I help you?",
+ "processing_message": "⏳ Processing, please wait. The results will be sent shortly."
}
}
}
diff --git a/docs/ja/chat-apps.md b/docs/ja/chat-apps.md
index 6d01c817b..54c6e4015 100644
--- a/docs/ja/chat-apps.md
+++ b/docs/ja/chat-apps.md
@@ -510,7 +510,8 @@ picoclaw gateway
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
- "welcome_message": "こんにちは!何かお手伝いできますか?"
+ "welcome_message": "こんにちは!何かお手伝いできますか?",
+ "processing_message": "⏳ Processing, please wait. The results will be sent shortly."
}
}
}
diff --git a/docs/vi/chat-apps.md b/docs/vi/chat-apps.md
index 1fefa00d3..5f527eabe 100644
--- a/docs/vi/chat-apps.md
+++ b/docs/vi/chat-apps.md
@@ -410,7 +410,8 @@ picoclaw gateway
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
- "welcome_message": "Hello! How can I help you?"
+ "welcome_message": "Hello! How can I help you?",
+ "processing_message": "⏳ Processing, please wait. The results will be sent shortly."
}
}
}
diff --git a/docs/zh/chat-apps.md b/docs/zh/chat-apps.md
index 4957fbcca..f082f7cf0 100644
--- a/docs/zh/chat-apps.md
+++ b/docs/zh/chat-apps.md
@@ -510,7 +510,8 @@ picoclaw gateway
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
- "welcome_message": "你好!有什么可以帮你的?"
+ "welcome_message": "你好!有什么可以帮你的?",
+ "processing_message": "⏳ Processing, please wait. The results will be sent shortly."
}
}
}
diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go
index 999f4f13b..2264b8492 100644
--- a/pkg/channels/wecom/aibot.go
+++ b/pkg/channels/wecom/aibot.go
@@ -158,6 +158,9 @@ func NewWeComAIBotChannel(
"WeCom AI Bot requires either (bot_id + secret) for WebSocket mode " +
"or (token + encoding_aes_key) for webhook mode")
}
+ if cfg.ProcessingMessage == "" {
+ cfg.ProcessingMessage = config.DefaultWeComAIBotProcessingMessage
+ }
base := channels.NewBaseChannel("wecom_aibot", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(2048),
@@ -709,7 +712,7 @@ func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce
default:
if time.Now().After(task.Deadline) {
// Deadline reached: close the stream with a notice, then wait for agent via response_url.
- content = "⏳ Processing, please wait. The results will be sent shortly."
+ content = c.config.ProcessingMessage
finish = true
closeStreamOnly = true
logger.InfoCF(
diff --git a/pkg/channels/wecom/aibot_test.go b/pkg/channels/wecom/aibot_test.go
index 7c5ae67b1..957b51c38 100644
--- a/pkg/channels/wecom/aibot_test.go
+++ b/pkg/channels/wecom/aibot_test.go
@@ -2,6 +2,7 @@ package wecom
import (
"context"
+ "encoding/json"
"testing"
"time"
@@ -134,6 +135,87 @@ func TestWeComAIBotChannelWebhookPath(t *testing.T) {
})
}
+func TestWeComAIBotChannelGetStreamResponseProcessingMessage(t *testing.T) {
+ validAESKey := "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"
+
+ t.Run("uses default processing message", func(t *testing.T) {
+ cfg := config.WeComAIBotConfig{
+ Enabled: true,
+ Token: "test_token",
+ EncodingAESKey: validAESKey,
+ }
+
+ messageBus := bus.NewMessageBus()
+ channel, err := NewWeComAIBotChannel(cfg, messageBus)
+ if err != nil {
+ t.Fatalf("Failed to create channel: %v", err)
+ }
+ ch, ok := channel.(*WeComAIBotChannel)
+ if !ok {
+ t.Fatal("Expected webhook mode channel")
+ }
+
+ task := &streamTask{
+ StreamID: "stream-default",
+ ChatID: "chat-default",
+ Deadline: time.Now().Add(-time.Second),
+ }
+ ch.streamTasks[task.StreamID] = task
+ ch.chatTasks[task.ChatID] = []*streamTask{task}
+
+ resp := decodeStreamResponse(t, ch, ch.getStreamResponse(task, "1234567890", "nonce"))
+
+ if !resp.Stream.Finish {
+ t.Fatal("Expected finished stream response after deadline")
+ }
+ if resp.Stream.Content != config.DefaultWeComAIBotProcessingMessage {
+ t.Fatalf("Expected default processing message %q, got %q",
+ config.DefaultWeComAIBotProcessingMessage, resp.Stream.Content)
+ }
+ if !task.StreamClosed {
+ t.Fatal("Expected task stream to be marked closed")
+ }
+ if _, ok := ch.streamTasks[task.StreamID]; ok {
+ t.Fatal("Expected closed stream task to be removed from streamTasks")
+ }
+ if len(ch.chatTasks[task.ChatID]) != 1 {
+ t.Fatalf("Expected task to remain queued for response_url delivery, got %d entries",
+ len(ch.chatTasks[task.ChatID]))
+ }
+ })
+
+ t.Run("uses custom processing message", func(t *testing.T) {
+ cfg := config.WeComAIBotConfig{
+ Enabled: true,
+ Token: "test_token",
+ EncodingAESKey: validAESKey,
+ ProcessingMessage: "Please wait a moment. The result will be delivered in a follow-up message.",
+ }
+
+ messageBus := bus.NewMessageBus()
+ channel, err := NewWeComAIBotChannel(cfg, messageBus)
+ if err != nil {
+ t.Fatalf("Failed to create channel: %v", err)
+ }
+ ch, ok := channel.(*WeComAIBotChannel)
+ if !ok {
+ t.Fatal("Expected webhook mode channel")
+ }
+
+ task := &streamTask{
+ StreamID: "stream-custom",
+ ChatID: "chat-custom",
+ Deadline: time.Now().Add(-time.Second),
+ }
+
+ resp := decodeStreamResponse(t, ch, ch.getStreamResponse(task, "1234567890", "nonce"))
+
+ if resp.Stream.Content != cfg.ProcessingMessage {
+ t.Fatalf("Expected custom processing message %q, got %q", cfg.ProcessingMessage, resp.Stream.Content)
+ }
+ })
+}
+
func TestGenerateStreamID(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
@@ -208,6 +290,27 @@ func TestGenerateSignature(t *testing.T) {
}
}
+func decodeStreamResponse(t *testing.T, ch *WeComAIBotChannel, encryptedResponse string) WeComAIBotStreamResponse {
+ t.Helper()
+
+ var wrapped WeComAIBotEncryptedResponse
+ if err := json.Unmarshal([]byte(encryptedResponse), &wrapped); err != nil {
+ t.Fatalf("Failed to unmarshal encrypted response: %v", err)
+ }
+
+ plaintext, err := decryptMessageWithVerify(wrapped.Encrypt, ch.config.EncodingAESKey, "")
+ if err != nil {
+ t.Fatalf("Failed to decrypt response: %v", err)
+ }
+
+ var resp WeComAIBotStreamResponse
+ if err := json.Unmarshal([]byte(plaintext), &resp); err != nil {
+ t.Fatalf("Failed to unmarshal decrypted response: %v", err)
+ }
+
+ return resp
+}
+
// ---- WebSocket long-connection mode tests ----
func TestNewWeComAIBotChannel_WSMode(t *testing.T) {
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 4f8026d27..33a5db8ae 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -247,7 +247,10 @@ type AgentDefaults struct {
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
}
-const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
+const (
+ DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
+ DefaultWeComAIBotProcessingMessage = "⏳ Processing, please wait. The results will be sent shortly."
+)
func (d *AgentDefaults) GetMaxMediaSize() int {
if d.MaxMediaSize > 0 {
@@ -474,17 +477,18 @@ type WeComAppConfig struct {
}
type WeComAIBotConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
- BotID string `json:"bot_id,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_BOT_ID"`
- Secret string `json:"secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_SECRET"`
- Token string `json:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
- EncodingAESKey string `json:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
- WebhookPath string `json:"webhook_path,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
- ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
- MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"`
- WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
+ BotID string `json:"bot_id,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_BOT_ID"`
+ Secret string `json:"secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_SECRET"`
+ Token string `json:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
+ EncodingAESKey string `json:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
+ WebhookPath string `json:"webhook_path,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
+ ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
+ MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps
+ WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome
+ ProcessingMessage string `json:"processing_message,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_PROCESSING_MESSAGE"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
}
type PicoConfig struct {
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 4038696c4..d44c73577 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -163,14 +163,15 @@ func DefaultConfig() *Config {
ReplyTimeout: 5,
},
WeComAIBot: WeComAIBotConfig{
- Enabled: false,
- Token: "",
- EncodingAESKey: "",
- WebhookPath: "/webhook/wecom-aibot",
- AllowFrom: FlexibleStringSlice{},
- ReplyTimeout: 5,
- MaxSteps: 10,
- WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?",
+ Enabled: false,
+ Token: "",
+ EncodingAESKey: "",
+ WebhookPath: "/webhook/wecom-aibot",
+ AllowFrom: FlexibleStringSlice{},
+ ReplyTimeout: 5,
+ MaxSteps: 10,
+ WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?",
+ ProcessingMessage: DefaultWeComAIBotProcessingMessage,
},
Pico: PicoConfig{
Enabled: false,
From 16a7da7517228b3396e5c47cdf25fe4f128ae21f Mon Sep 17 00:00:00 2001
From: Maksim
Date: Thu, 19 Mar 2026 19:25:00 +0300
Subject: [PATCH 13/44] docs: describe how to disable "exec" tool (#1703)
---
docs/fr/tools_configuration.md | 24 ++++++++++++++++++++++++
docs/ja/tools_configuration.md | 24 ++++++++++++++++++++++++
docs/pt-br/tools_configuration.md | 24 ++++++++++++++++++++++++
docs/tools_configuration.md | 24 ++++++++++++++++++++++++
docs/vi/tools_configuration.md | 24 ++++++++++++++++++++++++
docs/zh/tools_configuration.md | 24 ++++++++++++++++++++++++
6 files changed, 144 insertions(+)
diff --git a/docs/fr/tools_configuration.md b/docs/fr/tools_configuration.md
index 15573fc30..f6e1c0374 100644
--- a/docs/fr/tools_configuration.md
+++ b/docs/fr/tools_configuration.md
@@ -70,9 +70,32 @@ L'outil exec est utilisé pour exécuter des commandes shell.
| Config | Type | Par défaut | Description |
|------------------------|-------|------------|------------------------------------------------|
+| `enabled` | bool | true | Activer l'outil exec |
| `enable_deny_patterns` | bool | true | Activer le blocage par défaut des commandes dangereuses |
| `custom_deny_patterns` | array | [] | Modèles de refus personnalisés (expressions régulières) |
+### Désactivation de l'Outil Exec
+
+Pour désactiver complètement l'outil `exec`, définissez `enabled` à `false` :
+
+**Via le fichier de configuration :**
+```json
+{
+ "tools": {
+ "exec": {
+ "enabled": false
+ }
+ }
+}
+```
+
+**Via la variable d'environnement :**
+```bash
+PICOCLAW_TOOLS_EXEC_ENABLED=false
+```
+
+> **Note :** Lorsqu'il est désactivé, l'agent ne pourra pas exécuter de commandes shell. Cela affecte également la capacité de l'outil Cron à exécuter des commandes shell planifiées.
+
### Fonctionnalité
- **`enable_deny_patterns`** : Définir à `false` pour désactiver complètement les modèles de blocage par défaut des commandes dangereuses
@@ -329,6 +352,7 @@ Toutes les options de configuration peuvent être remplacées via des variables
Par exemple :
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
+- `PICOCLAW_TOOLS_EXEC_ENABLED=false`
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
diff --git a/docs/ja/tools_configuration.md b/docs/ja/tools_configuration.md
index e4568f6ae..c40e58538 100644
--- a/docs/ja/tools_configuration.md
+++ b/docs/ja/tools_configuration.md
@@ -70,9 +70,32 @@ Exec ツールはシェルコマンドの実行に使用されます。
| 設定項目 | 型 | デフォルト | 説明 |
|------------------------|-------|------------|------------------------------------|
+| `enabled` | bool | true | Exec ツールを有効にする |
| `enable_deny_patterns` | bool | true | デフォルトの危険コマンドブロックを有効にする |
| `custom_deny_patterns` | array | [] | カスタム拒否パターン(正規表現) |
+### Exec ツールの無効化
+
+`exec` ツールを完全に無効にするには、`enabled` を `false` に設定します:
+
+**設定ファイル経由:**
+```json
+{
+ "tools": {
+ "exec": {
+ "enabled": false
+ }
+ }
+}
+```
+
+**環境変数経由:**
+```bash
+PICOCLAW_TOOLS_EXEC_ENABLED=false
+```
+
+> **注意:** 無効にすると、エージェントはシェルコマンドを実行できなくなります。これは Cron ツールがスケジュールされたシェルコマンドを実行する能力にも影響します。
+
### 機能
- **`enable_deny_patterns`**:`false` に設定すると、デフォルトの危険コマンドブロックパターンを完全に無効にします
@@ -329,6 +352,7 @@ Skills ツールは ClawHub などのレジストリを通じたスキルの発
例:
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
+- `PICOCLAW_TOOLS_EXEC_ENABLED=false`
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
diff --git a/docs/pt-br/tools_configuration.md b/docs/pt-br/tools_configuration.md
index b6f726aa4..2cc4f3999 100644
--- a/docs/pt-br/tools_configuration.md
+++ b/docs/pt-br/tools_configuration.md
@@ -70,9 +70,32 @@ A ferramenta exec é usada para executar comandos shell.
| Config | Tipo | Padrão | Descrição |
|------------------------|-------|--------|-------------------------------------------------|
+| `enabled` | bool | true | Habilitar a ferramenta exec |
| `enable_deny_patterns` | bool | true | Habilitar bloqueio padrão de comandos perigosos |
| `custom_deny_patterns` | array | [] | Padrões de negação personalizados (expressões regulares) |
+### Desabilitando a Ferramenta Exec
+
+Para desabilitar completamente a ferramenta `exec`, defina `enabled` como `false`:
+
+**Via arquivo de configuração:**
+```json
+{
+ "tools": {
+ "exec": {
+ "enabled": false
+ }
+ }
+}
+```
+
+**Via variável de ambiente:**
+```bash
+PICOCLAW_TOOLS_EXEC_ENABLED=false
+```
+
+> **Nota:** Quando desabilitada, o agent não poderá executar comandos shell. Isso também afeta a capacidade da ferramenta Cron de executar comandos shell agendados.
+
### Funcionalidade
- **`enable_deny_patterns`**: Defina como `false` para desabilitar completamente os padrões de bloqueio de comandos perigosos padrão
@@ -329,6 +352,7 @@ Todas as opções de configuração podem ser substituídas via variáveis de am
Por exemplo:
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
+- `PICOCLAW_TOOLS_EXEC_ENABLED=false`
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md
index a38f0856f..2e0a22d3b 100644
--- a/docs/tools_configuration.md
+++ b/docs/tools_configuration.md
@@ -68,9 +68,32 @@ The exec tool is used to execute shell commands.
| Config | Type | Default | Description |
|------------------------|-------|---------|--------------------------------------------|
+| `enabled` | bool | true | Enable the exec tool |
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
+### Disabling the Exec Tool
+
+To completely disable the `exec` tool, set `enabled` to `false`:
+
+**Via config file:**
+```json
+{
+ "tools": {
+ "exec": {
+ "enabled": false
+ }
+ }
+}
+```
+
+**Via environment variable:**
+```bash
+PICOCLAW_TOOLS_EXEC_ENABLED=false
+```
+
+> **Note:** When disabled, the agent will not be able to execute shell commands. This also affects the Cron tool's ability to run scheduled shell commands.
+
### Functionality
- **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns
@@ -379,6 +402,7 @@ All configuration options can be overridden via environment variables with the f
For example:
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
+- `PICOCLAW_TOOLS_EXEC_ENABLED=false`
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
diff --git a/docs/vi/tools_configuration.md b/docs/vi/tools_configuration.md
index 6cc4dc8b6..76a336186 100644
--- a/docs/vi/tools_configuration.md
+++ b/docs/vi/tools_configuration.md
@@ -70,9 +70,32 @@ Công cụ exec được sử dụng để thực thi các lệnh shell.
| Cấu hình | Kiểu | Mặc định | Mô tả |
|--------------------------|-------|----------|------------------------------------------------|
+| `enabled` | bool | true | Bật công cụ exec |
| `enable_deny_patterns` | bool | true | Bật chặn lệnh nguy hiểm mặc định |
| `custom_deny_patterns` | array | [] | Mẫu từ chối tùy chỉnh (biểu thức chính quy) |
+### Vô hiệu hóa Công cụ Exec
+
+Để hoàn toàn vô hiệu hóa công cụ `exec`, đặt `enabled` thành `false`:
+
+**Qua tệp cấu hình:**
+```json
+{
+ "tools": {
+ "exec": {
+ "enabled": false
+ }
+ }
+}
+```
+
+**Qua biến môi trường:**
+```bash
+PICOCLAW_TOOLS_EXEC_ENABLED=false
+```
+
+> **Lưu ý:** Khi bị vô hiệu hóa, agent sẽ không thể thực thi lệnh shell. Điều này cũng ảnh hưởng đến khả năng chạy lệnh shell theo lịch của công cụ Cron.
+
### Chức năng
- **`enable_deny_patterns`**: Đặt thành `false` để tắt hoàn toàn các mẫu chặn lệnh nguy hiểm mặc định
@@ -329,6 +352,7 @@ Tất cả các tùy chọn cấu hình có thể được ghi đè qua biến m
Ví dụ:
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
+- `PICOCLAW_TOOLS_EXEC_ENABLED=false`
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
diff --git a/docs/zh/tools_configuration.md b/docs/zh/tools_configuration.md
index ff88b6707..e10e3d26a 100644
--- a/docs/zh/tools_configuration.md
+++ b/docs/zh/tools_configuration.md
@@ -70,9 +70,32 @@ Exec 工具用于执行 shell 命令。
| 配置项 | 类型 | 默认值 | 描述 |
|------------------------|-------|--------|--------------------------------|
+| `enabled` | bool | true | 启用 exec 工具 |
| `enable_deny_patterns` | bool | true | 启用默认的危险命令拦截 |
| `custom_deny_patterns` | array | [] | 自定义拒绝模式(正则表达式) |
+### 禁用 Exec 工具
+
+要完全禁用 `exec` 工具,请将 `enabled` 设置为 `false`:
+
+**通过配置文件:**
+```json
+{
+ "tools": {
+ "exec": {
+ "enabled": false
+ }
+ }
+}
+```
+
+**通过环境变量:**
+```bash
+PICOCLAW_TOOLS_EXEC_ENABLED=false
+```
+
+> **注意:** 禁用后,代理将无法执行 shell 命令。这也会影响 Cron 工具运行计划 shell 命令的能力。
+
### 功能说明
- **`enable_deny_patterns`**:设为 `false` 可完全禁用默认的危险命令拦截模式
@@ -329,6 +352,7 @@ Skills 工具配置通过 ClawHub 等注册表进行技能发现和安装。
例如:
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
+- `PICOCLAW_TOOLS_EXEC_ENABLED=false`
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
From 5ada0dfed35c6c67bd9da7d2050da9cb5629fd11 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 19 Mar 2026 17:17:48 +0000
Subject: [PATCH 14/44] chore(deps): bump goreleaser/goreleaser-action from 6
to 7
Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 6 to 7.
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](https://github.com/goreleaser/goreleaser-action/compare/v6...v7)
---
updated-dependencies:
- dependency-name: goreleaser/goreleaser-action
dependency-version: '7'
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
---
.github/workflows/nightly.yml | 2 +-
.github/workflows/release.yml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index e001dc3e9..9ee8ec2d4 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -79,7 +79,7 @@ jobs:
run: git tag "${{ steps.version.outputs.version }}"
- name: Run GoReleaser
- uses: goreleaser/goreleaser-action@v6
+ uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: ~> v2
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 19c8e5404..fc8d0326a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -94,7 +94,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Run GoReleaser
- uses: goreleaser/goreleaser-action@v6
+ uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: ~> v2
From 876898fec6d930afd824c1af0f848b680093942a Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 19 Mar 2026 17:17:54 +0000
Subject: [PATCH 15/44] chore(deps): bump docker/setup-qemu-action from 3 to 4
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)
---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
dependency-version: '4'
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
---
.github/workflows/nightly.yml | 2 +-
.github/workflows/release.yml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index e001dc3e9..15adaede8 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -56,7 +56,7 @@ jobs:
run: corepack enable && corepack prepare pnpm@latest --activate
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 19c8e5404..6612938c3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -74,7 +74,7 @@ jobs:
run: corepack enable && corepack prepare pnpm@latest --activate
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
From 80d9a90c5217b80661915689f4e8a1775bd2551d Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 20 Mar 2026 11:45:37 +0800
Subject: [PATCH 16/44] chore(deps): bump github.com/ergochat/irc-go from 0.5.0
to 0.6.0 (#1800)
Bumps [github.com/ergochat/irc-go](https://github.com/ergochat/irc-go) from 0.5.0 to 0.6.0.
- [Release notes](https://github.com/ergochat/irc-go/releases)
- [Changelog](https://github.com/ergochat/irc-go/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ergochat/irc-go/compare/v0.5.0...v0.6.0)
---
updated-dependencies:
- dependency-name: github.com/ergochat/irc-go
dependency-version: 0.6.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 | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/go.mod b/go.mod
index 4442b28fe..19f8da73e 100644
--- a/go.mod
+++ b/go.mod
@@ -8,7 +8,7 @@ require (
github.com/anthropics/anthropic-sdk-go v1.26.0
github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.4.0
- github.com/ergochat/irc-go v0.5.0
+ github.com/ergochat/irc-go v0.6.0
github.com/ergochat/readline v0.1.3
github.com/gdamore/tcell/v2 v2.13.8
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
@@ -93,7 +93,7 @@ require (
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/arch v0.24.0 // indirect
golang.org/x/crypto v0.48.0
- golang.org/x/net v0.51.0 // indirect
+ golang.org/x/net v0.51.0
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
)
diff --git a/go.sum b/go.sum
index f0e3fc132..86dda730e 100644
--- a/go.sum
+++ b/go.sum
@@ -46,8 +46,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
-github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw=
-github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
+github.com/ergochat/irc-go v0.6.0 h1:Y0AGV76aeihJfCtLaQh+OyJKFiKGrYC0VTkeMZ6XW28=
+github.com/ergochat/irc-go v0.6.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4pSo=
github.com/ergochat/readline v0.1.3/go.mod h1:o3ux9QLHLm77bq7hDB21UTm6HlV2++IPDMfIfKDuOgY=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
From 77d0c67e58c44c26ef44cbf9e0756eb9d52e606d Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 20 Mar 2026 11:50:56 +0800
Subject: [PATCH 17/44] chore(deps): bump @tabler/icons-react in /web/frontend
(#1803)
Bumps [@tabler/icons-react](https://github.com/tabler/tabler-icons/tree/HEAD/packages/icons-react) from 3.38.0 to 3.40.0.
- [Release notes](https://github.com/tabler/tabler-icons/releases)
- [Commits](https://github.com/tabler/tabler-icons/commits/v3.40.0/packages/icons-react)
---
updated-dependencies:
- dependency-name: "@tabler/icons-react"
dependency-version: 3.40.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>
---
web/frontend/package.json | 2 +-
web/frontend/pnpm-lock.yaml | 18 +++++++++---------
2 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/web/frontend/package.json b/web/frontend/package.json
index 2e0e37117..e445546d7 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -14,7 +14,7 @@
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
- "@tabler/icons-react": "^3.38.0",
+ "@tabler/icons-react": "^3.40.0",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.167.0",
diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml
index 20f0a7342..86d6790a1 100644
--- a/web/frontend/pnpm-lock.yaml
+++ b/web/frontend/pnpm-lock.yaml
@@ -12,8 +12,8 @@ importers:
specifier: ^5.2.8
version: 5.2.8
'@tabler/icons-react':
- specifier: ^3.38.0
- version: 3.38.0(react@19.2.4)
+ specifier: ^3.40.0
+ version: 3.40.0(react@19.2.4)
'@tailwindcss/vite':
specifier: ^4.2.1
version: 4.2.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
@@ -1460,13 +1460,13 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
- '@tabler/icons-react@3.38.0':
- resolution: {integrity: sha512-kR5wv+m4+GgmnSszg3rQd6SrTFAQ/XnQC/yTwIfuRJSfqB12KoIC7fPbIijFgOHTFlBN5DARnN0IVrR7KYG6/A==}
+ '@tabler/icons-react@3.40.0':
+ resolution: {integrity: sha512-oO5+6QCnna4a//mYubx4euZfECtzQZFDGsDMIdzZUhbdyBCT+3bRVFBPueGIcemWld4Vb/0UQ39C/cmGfGylAg==}
peerDependencies:
react: '>= 16'
- '@tabler/icons@3.38.0':
- resolution: {integrity: sha512-FdETQSpQ3lN7BEjEUzjKhsfTDCamrvMDops4HEMphTm3DmkIFpThoODn8XXZ8Q9MhjshIvphIYVHHB7zpq167w==}
+ '@tabler/icons@3.40.0':
+ resolution: {integrity: sha512-V/Q4VgNPKubRTiLdmWjV/zscYcj5IIk+euicUtaVVqF6luSC9rDngYWgST5/yh3Mrg/mYUwRv1YVTk71Jp0twQ==}
'@tailwindcss/node@4.2.1':
resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
@@ -5293,12 +5293,12 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
- '@tabler/icons-react@3.38.0(react@19.2.4)':
+ '@tabler/icons-react@3.40.0(react@19.2.4)':
dependencies:
- '@tabler/icons': 3.38.0
+ '@tabler/icons': 3.40.0
react: 19.2.4
- '@tabler/icons@3.38.0': {}
+ '@tabler/icons@3.40.0': {}
'@tailwindcss/node@4.2.1':
dependencies:
From c9ac19c0ccb589746c597ad1b3cc52952ea4f431 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 20 Mar 2026 11:58:24 +0800
Subject: [PATCH 18/44] chore(deps): bump maunium.net/go/mautrix from 0.26.3 to
0.26.4 (#1805)
Bumps [maunium.net/go/mautrix](https://github.com/mautrix/go) from 0.26.3 to 0.26.4.
- [Release notes](https://github.com/mautrix/go/releases)
- [Changelog](https://github.com/mautrix/go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mautrix/go/compare/v0.26.3...v0.26.4)
---
updated-dependencies:
- dependency-name: maunium.net/go/mautrix
dependency-version: 0.26.4
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 | 22 +++++++++++-----------
go.sum | 52 ++++++++++++++++++++++++++--------------------------
2 files changed, 37 insertions(+), 37 deletions(-)
diff --git a/go.mod b/go.mod
index 19f8da73e..e858c3642 100644
--- a/go.mod
+++ b/go.mod
@@ -29,16 +29,16 @@ require (
github.com/tencent-connect/botgo v0.2.1
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
golang.org/x/oauth2 v0.36.0
- golang.org/x/term v0.40.0
+ golang.org/x/term v0.41.0
golang.org/x/time v0.14.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
- maunium.net/go/mautrix v0.26.3
+ maunium.net/go/mautrix v0.26.4
modernc.org/sqlite v1.46.1
)
require (
- filippo.io/edwards25519 v1.1.1 // indirect
+ filippo.io/edwards25519 v1.2.0 // indirect
github.com/beeper/argo-go v1.1.2 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@@ -51,7 +51,7 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
- github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
+ github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // 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
@@ -60,9 +60,9 @@ 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.mau.fi/util v0.9.6 // indirect
- golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
- golang.org/x/text v0.34.0 // indirect
+ go.mau.fi/util v0.9.7 // 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/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
@@ -92,8 +92,8 @@ require (
github.com/valyala/fastjson v1.6.10 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/arch v0.24.0 // indirect
- golang.org/x/crypto v0.48.0
- golang.org/x/net v0.51.0
- golang.org/x/sync v0.19.0 // indirect
- golang.org/x/sys v0.41.0 // indirect
+ golang.org/x/crypto v0.49.0
+ golang.org/x/net v0.52.0
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.42.0 // indirect
)
diff --git a/go.sum b/go.sum
index 86dda730e..2e4816018 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,6 @@
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
-filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
-filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
+filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
@@ -158,8 +158,8 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
-github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
-github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
+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/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=
@@ -235,8 +235,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
-go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts=
-go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI=
+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.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -250,16 +250,16 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
-golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
-golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
-golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
+golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
+golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
+golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
+golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
-golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
+golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
+golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -273,8 +273,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
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=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
-golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
-golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
+golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
@@ -284,8 +284,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
-golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -307,15 +307,15 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
-golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+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-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=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
-golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
-golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
+golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
+golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -323,8 +323,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
-golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -334,8 +334,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
-golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
+golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
+golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -365,8 +365,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-maunium.net/go/mautrix v0.26.3 h1:tWZih6Vjw0qGTWuPmg9JUrQPzViTNDPGQLVc5UXC4nk=
-maunium.net/go/mautrix v0.26.3/go.mod h1:v5ZdDoCwUpNqEj5OrhEoUa3L1kEddKPaAya9TgGXN38=
+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=
From 736baf2217b2b71d30a7bfd15d5cd1200d5ae52b Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 20 Mar 2026 12:02:56 +0800
Subject: [PATCH 19/44] chore(deps-dev): bump @types/node in /web/frontend
(#1806)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.11.0 to 25.5.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)
---
updated-dependencies:
- dependency-name: "@types/node"
dependency-version: 25.5.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 | 70 ++++++++++++++++++-------------------
2 files changed, 36 insertions(+), 36 deletions(-)
diff --git a/web/frontend/package.json b/web/frontend/package.json
index e445546d7..3cef8bef9 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -44,7 +44,7 @@
"@tailwindcss/typography": "^0.5.19",
"@tanstack/router-plugin": "^1.164.0",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
- "@types/node": "^24.10.1",
+ "@types/node": "^25.5.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.56.1",
diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml
index 86d6790a1..056ae56c9 100644
--- a/web/frontend/pnpm-lock.yaml
+++ b/web/frontend/pnpm-lock.yaml
@@ -16,7 +16,7 @@ importers:
version: 3.40.0(react@19.2.4)
'@tailwindcss/vite':
specifier: ^4.2.1
- version: 4.2.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
+ version: 4.2.1(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
'@tanstack/react-query':
specifier: ^5.90.21
version: 5.90.21(react@19.2.4)
@@ -67,7 +67,7 @@ importers:
version: 4.0.1
shadcn:
specifier: ^4.0.5
- version: 4.0.5(@types/node@24.11.0)(typescript@5.9.3)
+ version: 4.0.5(@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)
@@ -92,13 +92,13 @@ importers:
version: 0.5.19(tailwindcss@4.2.1)
'@tanstack/router-plugin':
specifier: ^1.164.0
- version: 1.164.0(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
+ version: 1.164.0(@tanstack/react-router@1.167.0(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.31.1)(tsx@4.21.0))
'@trivago/prettier-plugin-sort-imports':
specifier: ^6.0.2
version: 6.0.2(prettier@3.8.1)
'@types/node':
- specifier: ^24.10.1
- version: 24.11.0
+ specifier: ^25.5.0
+ version: 25.5.0
'@types/react':
specifier: ^19.2.7
version: 19.2.14
@@ -110,7 +110,7 @@ importers:
version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(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@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
+ version: 5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
eslint:
specifier: ^9.39.3
version: 9.39.3(jiti@2.6.1)
@@ -140,7 +140,7 @@ importers:
version: 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
vite:
specifier: ^7.3.1
- version: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)
+ version: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)
packages:
@@ -1712,8 +1712,8 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
- '@types/node@24.11.0':
- resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==}
+ '@types/node@25.5.0':
+ resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
@@ -3728,8 +3728,8 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
- undici-types@7.16.0:
- resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+ undici-types@7.18.2:
+ resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
@@ -4360,31 +4360,31 @@ snapshots:
'@inquirer/ansi@1.0.2': {}
- '@inquirer/confirm@5.1.21(@types/node@24.11.0)':
+ '@inquirer/confirm@5.1.21(@types/node@25.5.0)':
dependencies:
- '@inquirer/core': 10.3.2(@types/node@24.11.0)
- '@inquirer/type': 3.0.10(@types/node@24.11.0)
+ '@inquirer/core': 10.3.2(@types/node@25.5.0)
+ '@inquirer/type': 3.0.10(@types/node@25.5.0)
optionalDependencies:
- '@types/node': 24.11.0
+ '@types/node': 25.5.0
- '@inquirer/core@10.3.2(@types/node@24.11.0)':
+ '@inquirer/core@10.3.2(@types/node@25.5.0)':
dependencies:
'@inquirer/ansi': 1.0.2
'@inquirer/figures': 1.0.15
- '@inquirer/type': 3.0.10(@types/node@24.11.0)
+ '@inquirer/type': 3.0.10(@types/node@25.5.0)
cli-width: 4.1.0
mute-stream: 2.0.0
signal-exit: 4.1.0
wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.3
optionalDependencies:
- '@types/node': 24.11.0
+ '@types/node': 25.5.0
'@inquirer/figures@1.0.15': {}
- '@inquirer/type@3.0.10(@types/node@24.11.0)':
+ '@inquirer/type@3.0.10(@types/node@25.5.0)':
optionalDependencies:
- '@types/node': 24.11.0
+ '@types/node': 25.5.0
'@jridgewell/gen-mapping@0.3.13':
dependencies:
@@ -5366,12 +5366,12 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 4.2.1
- '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))':
+ '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))':
dependencies:
'@tailwindcss/node': 4.2.1
'@tailwindcss/oxide': 4.2.1
tailwindcss: 4.2.1
- vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)
+ vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)
'@tanstack/history@1.161.4': {}
@@ -5453,7 +5453,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@tanstack/router-plugin@1.164.0(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))':
+ '@tanstack/router-plugin@1.164.0(@tanstack/react-router@1.167.0(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.31.1)(tsx@4.21.0))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
@@ -5470,7 +5470,7 @@ snapshots:
zod: 3.25.76
optionalDependencies:
'@tanstack/react-router': 1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)
+ vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)
transitivePeerDependencies:
- supports-color
@@ -5557,9 +5557,9 @@ snapshots:
'@types/ms@2.1.0': {}
- '@types/node@24.11.0':
+ '@types/node@25.5.0':
dependencies:
- undici-types: 7.16.0
+ undici-types: 7.18.2
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
@@ -5670,7 +5670,7 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
- '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))':
+ '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.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)
@@ -5678,7 +5678,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-rc.3
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
- vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)
+ vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)
transitivePeerDependencies:
- supports-color
@@ -7039,9 +7039,9 @@ snapshots:
ms@2.1.3: {}
- msw@2.12.10(@types/node@24.11.0)(typescript@5.9.3):
+ msw@2.12.10(@types/node@25.5.0)(typescript@5.9.3):
dependencies:
- '@inquirer/confirm': 5.1.21(@types/node@24.11.0)
+ '@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
@@ -7565,7 +7565,7 @@ snapshots:
setprototypeof@1.2.0: {}
- shadcn@4.0.5(@types/node@24.11.0)(typescript@5.9.3):
+ shadcn@4.0.5(@types/node@25.5.0)(typescript@5.9.3):
dependencies:
'@antfu/ni': 25.0.0
'@babel/core': 7.29.0
@@ -7587,7 +7587,7 @@ snapshots:
fuzzysort: 3.1.0
https-proxy-agent: 7.0.6
kleur: 4.1.5
- msw: 2.12.10(@types/node@24.11.0)(typescript@5.9.3)
+ msw: 2.12.10(@types/node@25.5.0)(typescript@5.9.3)
node-fetch: 3.3.2
open: 11.0.0
ora: 8.2.0
@@ -7816,7 +7816,7 @@ snapshots:
typescript@5.9.3: {}
- undici-types@7.16.0: {}
+ undici-types@7.18.2: {}
unicorn-magic@0.3.0: {}
@@ -7930,7 +7930,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
- vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0):
+ vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0):
dependencies:
esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3)
@@ -7939,7 +7939,7 @@ snapshots:
rollup: 4.59.0
tinyglobby: 0.2.15
optionalDependencies:
- '@types/node': 24.11.0
+ '@types/node': 25.5.0
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.31.1
From 8a488eeeedf948ab6665f4ff1d4903dad31d198e Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 20 Mar 2026 12:11:25 +0800
Subject: [PATCH 20/44] chore(deps-dev): bump typescript-eslint in
/web/frontend (#1807)
Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.56.1 to 8.57.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.57.1/packages/typescript-eslint)
---
updated-dependencies:
- dependency-name: typescript-eslint
dependency-version: 8.57.1
dependency-type: direct:development
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 | 183 +++++++++++++++++++++++++++++++-----
2 files changed, 163 insertions(+), 22 deletions(-)
diff --git a/web/frontend/package.json b/web/frontend/package.json
index 3cef8bef9..e20c28011 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -57,7 +57,7 @@
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"typescript": "~5.9.3",
- "typescript-eslint": "^8.48.0",
+ "typescript-eslint": "^8.57.1",
"vite": "^7.3.1"
}
}
diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml
index 056ae56c9..ee047194d 100644
--- a/web/frontend/pnpm-lock.yaml
+++ b/web/frontend/pnpm-lock.yaml
@@ -107,7 +107,7 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@typescript-eslint/eslint-plugin':
specifier: ^8.56.1
- version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
+ version: 8.56.1(@typescript-eslint/parser@8.57.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(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.31.1)(tsx@4.21.0))
@@ -136,8 +136,8 @@ importers:
specifier: ~5.9.3
version: 5.9.3
typescript-eslint:
- specifier: ^8.48.0
- version: 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
+ specifier: ^8.57.1
+ version: 8.57.1(eslint@9.39.3(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.31.1)(tsx@4.21.0)
@@ -1743,8 +1743,16 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/parser@8.56.1':
- resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==}
+ '@typescript-eslint/eslint-plugin@8.57.1':
+ resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.57.1
+ 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==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -1756,16 +1764,32 @@ packages:
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
+ '@typescript-eslint/project-service@8.57.1':
+ resolution: {integrity: sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
'@typescript-eslint/scope-manager@8.56.1':
resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript-eslint/scope-manager@8.57.1':
+ resolution: {integrity: sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@typescript-eslint/tsconfig-utils@8.56.1':
resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
+ '@typescript-eslint/tsconfig-utils@8.57.1':
+ resolution: {integrity: sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
'@typescript-eslint/type-utils@8.56.1':
resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1773,16 +1797,33 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
+ '@typescript-eslint/type-utils@8.57.1':
+ resolution: {integrity: sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==}
+ 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.56.1':
resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript-eslint/types@8.57.1':
+ resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@typescript-eslint/typescript-estree@8.56.1':
resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
+ '@typescript-eslint/typescript-estree@8.57.1':
+ resolution: {integrity: sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
'@typescript-eslint/utils@8.56.1':
resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1790,10 +1831,21 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
+ '@typescript-eslint/utils@8.57.1':
+ resolution: {integrity: sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==}
+ 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.56.1':
resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript-eslint/visitor-keys@8.57.1':
+ resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
@@ -3686,6 +3738,12 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
+ ts-api-utils@2.5.0:
+ resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
+ engines: {node: '>=18.12'}
+ peerDependencies:
+ typescript: '>=4.8.4'
+
ts-morph@26.0.0:
resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==}
@@ -3716,8 +3774,8 @@ packages:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'}
- typescript-eslint@8.56.1:
- resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==}
+ typescript-eslint@8.57.1:
+ resolution: {integrity: sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -5577,10 +5635,10 @@ snapshots:
'@types/validate-npm-package-name@4.0.2': {}
- '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.57.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.57.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.56.1
'@typescript-eslint/type-utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
@@ -5593,12 +5651,28 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/scope-manager': 8.56.1
- '@typescript-eslint/types': 8.56.1
- '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
- '@typescript-eslint/visitor-keys': 8.56.1
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.57.1(eslint@9.39.3(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.3(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.57.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.57.1
+ eslint: 9.39.3(jiti@2.6.1)
+ ignore: 7.0.5
+ natural-compare: 1.4.0
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/parser@8.57.1(eslint@9.39.3(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
debug: 4.4.3
eslint: 9.39.3(jiti@2.6.1)
typescript: 5.9.3
@@ -5607,8 +5681,17 @@ snapshots:
'@typescript-eslint/project-service@8.56.1(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3)
- '@typescript-eslint/types': 8.56.1
+ '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.57.1
+ debug: 4.4.3
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.57.1(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.57.1
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
@@ -5619,10 +5702,19 @@ snapshots:
'@typescript-eslint/types': 8.56.1
'@typescript-eslint/visitor-keys': 8.56.1
+ '@typescript-eslint/scope-manager@8.57.1':
+ dependencies:
+ '@typescript-eslint/types': 8.57.1
+ '@typescript-eslint/visitor-keys': 8.57.1
+
'@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
+ '@typescript-eslint/tsconfig-utils@8.57.1(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
'@typescript-eslint/type-utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.56.1
@@ -5635,8 +5727,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/type-utils@8.57.1(eslint@9.39.3(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.3(jiti@2.6.1))(typescript@5.9.3)
+ debug: 4.4.3
+ eslint: 9.39.3(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.56.1': {}
+ '@typescript-eslint/types@8.57.1': {}
+
'@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.56.1(typescript@5.9.3)
@@ -5652,6 +5758,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/typescript-estree@8.57.1(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
+ debug: 4.4.3
+ minimatch: 10.2.4
+ semver: 7.7.4
+ tinyglobby: 0.2.15
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1))
@@ -5663,11 +5784,27 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/utils@8.57.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(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.3(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/visitor-keys@8.56.1':
dependencies:
'@typescript-eslint/types': 8.56.1
eslint-visitor-keys: 5.0.1
+ '@typescript-eslint/visitor-keys@8.57.1':
+ dependencies:
+ '@typescript-eslint/types': 8.57.1
+ 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.31.1)(tsx@4.21.0))':
@@ -7767,6 +7904,10 @@ snapshots:
dependencies:
typescript: 5.9.3
+ ts-api-utils@2.5.0(typescript@5.9.3):
+ dependencies:
+ typescript: 5.9.3
+
ts-morph@26.0.0:
dependencies:
'@ts-morph/common': 0.27.0
@@ -7803,12 +7944,12 @@ snapshots:
media-typer: 1.1.0
mime-types: 3.0.2
- typescript-eslint@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3):
+ typescript-eslint@8.57.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
- '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/eslint-plugin': 8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.57.1(eslint@9.39.3(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.3(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.3(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
From 009a8d702bcf58b2b1f78ac0fb8b20f0c44a0da2 Mon Sep 17 00:00:00 2001
From: ywj <138745068+yangwenjie1231@users.noreply.github.com>
Date: Fri, 20 Mar 2026 04:59:43 +0000
Subject: [PATCH 21/44] Feat/feishu card parsing (#1534)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(feishu): add interactive card message parsing
Add support for parsing inbound Feishu interactive card messages.
When a user sends a card message, the text content is now extracted
and passed to the LLM for processing.
- Add extractCardText() to recursively extract text from card JSON
- Support both JSON 1.0 (legacy) and JSON 2.0 schema formats
- Handle nested elements: header, body, actions, columns
- Extract text from markdown, lark_md, and plain_text elements
- Add comprehensive unit tests for card parsing
Fixes #
💘 Generated with Crush
Assisted-by: GLM-5 via Crush
* feat(feishu): extract and download images from interactive cards
When receiving interactive card messages, extract embedded images
(img_key, src, icon_key) and download them for LLM processing.
- Add extractCardImageKeys() to recursively extract image keys from card JSON
- Support img elements (img_key, src) and icon elements (icon_key)
- Update downloadInboundMedia() to handle MsgTypeInteractive
- Add comprehensive unit tests for image extraction
Images are downloaded and stored via MediaStore, then appended to
the message content as [image: photo] tags for LLM visibility.
💘 Generated with Crush
Assisted-by: GLM-5 via Crush
* fix(feishu): simplify card parsing - pass raw JSON, only extract images
Address review feedback: text extraction cannot exhaustively handle all
card formats (i18n_elements, div.fields, etc.). Pass raw JSON to LLM
instead - same approach as MsgTypePost. Only image extraction remains
as images must be downloaded for LLM to process.
- Remove extractCardText() and helper functions
- extractContent() now returns raw JSON for MsgTypeInteractive
- Keep extractCardImageKeys() for downloading embedded images
- Update tests to expect raw JSON for interactive cards
* fix(feishu): don't append media tags to interactive card JSON
Appending media tags like "[attachment]" to raw JSON content produces
invalid JSON format. For interactive cards, the JSON already contains
image information and media refs are downloaded separately.
- Skip appendMediaTags for MsgTypeInteractive to preserve valid JSON
- Add test case for interactive card with images
* fix(feishu): filter out external URLs from card image extraction
Only Feishu-hosted image keys (img_xxx, icon_xxx) can be downloaded via
the Feishu API. External URLs in src field (https://...) should be
filtered out to avoid download failures.
- Add isFeishuImageKey() to detect Feishu-hosted keys vs external URLs
- Update extractImageKeysRecursive to skip external URLs in src field
- Add tests for external URL filtering and mixed scenarios
* feat(feishu): support downloading external images from interactive cards
Previously only Feishu-hosted images (img_key, icon_key) could be
downloaded. Now external URLs in src field are also downloaded via
HTTP and made available to the LLM.
- extractCardImageKeys now returns two slices: Feishu keys and external URLs
- Add downloadExternalImage to download images from HTTP URLs
- Update downloadInboundMedia to handle both Feishu API and HTTP downloads
- Update tests for new function signature
* fix(feishu): use HTTP client with timeout for external image downloads
Replaced http.DefaultClient with a client that has a 30-second timeout
to prevent hanging on unresponsive external URLs.
Generated with Crush
Assisted-by: GLM-5 via Crush
* fix(feishu): resolve lint errors for shadow and formatting
- Rename err variables to avoid shadowing in downloadExternalImage
- Fix struct field alignment in TestExtractCardImageKeys
Generated with Crush
Assisted-by: GLM-5 via Crush
* refactor(feishu): pass external image URLs to LLM instead of downloading
Instead of downloading external images from interactive cards, pass
the URLs directly to LLM. This reduces network overhead and lets
vision-capable models fetch images as needed.
- Remove downloadExternalImage function
- Append external URLs to card content for LLM processing
- Only download Feishu-hosted images via API
💘 Generated with Crush
Assisted-by: GLM-5 via Crush
* fix(feishu): add blank line between functions for gci formatting
* fix(feishu): keep interactive card content as valid JSON
---
pkg/channels/feishu/common.go | 61 ++++++++++++++
pkg/channels/feishu/common_test.go | 116 ++++++++++++++++++++++++++
pkg/channels/feishu/feishu_64.go | 32 +++++++
pkg/channels/feishu/feishu_64_test.go | 25 ++++++
4 files changed, 234 insertions(+)
diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go
index fbe085b73..4952394b7 100644
--- a/pkg/channels/feishu/common.go
+++ b/pkg/channels/feishu/common.go
@@ -84,3 +84,64 @@ func stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) s
content = mentionPlaceholderRegex.ReplaceAllString(content, "")
return strings.TrimSpace(content)
}
+
+// extractCardImageKeys recursively extracts all image keys from a Feishu interactive card.
+// Image keys are used to download images from Feishu API.
+// Returns two slices: Feishu-hosted keys and external URLs.
+func extractCardImageKeys(rawContent string) (feishuKeys []string, externalURLs []string) {
+ if rawContent == "" {
+ return nil, nil
+ }
+
+ var card map[string]any
+ if err := json.Unmarshal([]byte(rawContent), &card); err != nil {
+ return nil, nil
+ }
+
+ extractImageKeysRecursive(card, &feishuKeys, &externalURLs)
+ return feishuKeys, externalURLs
+}
+
+// isExternalURL returns true if the string is an external HTTP/HTTPS URL.
+func isExternalURL(s string) bool {
+ return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
+}
+
+// extractImageKeysRecursive traverses card structure to find all image keys.
+// Collects both Feishu-hosted keys and external URLs separately.
+func extractImageKeysRecursive(v any, feishuKeys, externalURLs *[]string) {
+ switch val := v.(type) {
+ case map[string]any:
+ // Check if this is an img element
+ if tag, ok := val["tag"].(string); ok {
+ switch tag {
+ case "img":
+ // Try img_key first (always Feishu-hosted)
+ if imgKey, ok := val["img_key"].(string); ok && imgKey != "" {
+ *feishuKeys = append(*feishuKeys, imgKey)
+ }
+ // Check src - could be Feishu key or external URL
+ if src, ok := val["src"].(string); ok && src != "" {
+ if isExternalURL(src) {
+ *externalURLs = append(*externalURLs, src)
+ } else {
+ *feishuKeys = append(*feishuKeys, src)
+ }
+ }
+ case "icon":
+ // Icon elements use icon_key
+ if iconKey, ok := val["icon_key"].(string); ok && iconKey != "" {
+ *feishuKeys = append(*feishuKeys, iconKey)
+ }
+ }
+ }
+ // Recurse into all nested structures
+ for _, child := range val {
+ extractImageKeysRecursive(child, feishuKeys, externalURLs)
+ }
+ case []any:
+ for _, item := range val {
+ extractImageKeysRecursive(item, feishuKeys, externalURLs)
+ }
+ }
+}
diff --git a/pkg/channels/feishu/common_test.go b/pkg/channels/feishu/common_test.go
index fefc9f7c1..ff4af0148 100644
--- a/pkg/channels/feishu/common_test.go
+++ b/pkg/channels/feishu/common_test.go
@@ -290,3 +290,119 @@ func TestStripMentionPlaceholders(t *testing.T) {
})
}
}
+
+func TestExtractCardImageKeys(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ wantFeishuKeys []string
+ wantExternalURLs []string
+ }{
+ {
+ name: "empty content",
+ content: "",
+ wantFeishuKeys: nil,
+ wantExternalURLs: nil,
+ },
+ {
+ name: "invalid JSON",
+ content: "not json",
+ wantFeishuKeys: nil,
+ wantExternalURLs: nil,
+ },
+ {
+ name: "card with no images",
+ content: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"text"}]}}`,
+ wantFeishuKeys: nil,
+ wantExternalURLs: nil,
+ },
+ {
+ name: "single image with img_key",
+ content: `{"elements":[{"tag":"img","img_key":"img_abc123"}]}`,
+ wantFeishuKeys: []string{"img_abc123"},
+ wantExternalURLs: nil,
+ },
+ {
+ name: "single image with src as Feishu key",
+ content: `{"elements":[{"tag":"img","src":"img_xyz789"}]}`,
+ wantFeishuKeys: []string{"img_xyz789"},
+ wantExternalURLs: nil,
+ },
+ {
+ name: "multiple images",
+ content: `{"elements":[{"tag":"img","img_key":"img_1"},{"tag":"div","text":{"content":"text"}},{"tag":"img","img_key":"img_2"}]}`,
+ wantFeishuKeys: []string{"img_1", "img_2"},
+ wantExternalURLs: nil,
+ },
+ {
+ name: "nested image in columns",
+ content: `{"elements":[{"tag":"div","columns":[{"tag":"img","img_key":"img_col1"},{"tag":"img","img_key":"img_col2"}]}]}`,
+ wantFeishuKeys: []string{"img_col1", "img_col2"},
+ wantExternalURLs: nil,
+ },
+ {
+ name: "image in action",
+ content: `{"elements":[{"tag":"action","actions":[{"tag":"img","img_key":"img_action"}]}]}`,
+ wantFeishuKeys: []string{"img_action"},
+ wantExternalURLs: nil,
+ },
+ {
+ name: "icon element",
+ content: `{"elements":[{"tag":"icon","icon_key":"icon_123"}]}`,
+ wantFeishuKeys: []string{"icon_123"},
+ wantExternalURLs: nil,
+ },
+ {
+ name: "complex card with text and images",
+ content: `{"header":{"title":{"content":"Title"}},"elements":[{"tag":"div","text":{"content":"Description"}},{"tag":"img","img_key":"img_main"}]}`,
+ wantFeishuKeys: []string{"img_main"},
+ wantExternalURLs: nil,
+ },
+ {
+ name: "external URL in src",
+ content: `{"elements":[{"tag":"img","src":"https://example.com/image.png"}]}`,
+ wantFeishuKeys: nil,
+ wantExternalURLs: []string{"https://example.com/image.png"},
+ },
+ {
+ name: "mixed Feishu keys and external URLs",
+ content: `{"elements":[{"tag":"img","img_key":"img_feishu"},{"tag":"img","src":"https://cdn.example.com/external.jpg"},{"tag":"img","src":"img_another"}]}`,
+ wantFeishuKeys: []string{"img_feishu", "img_another"},
+ wantExternalURLs: []string{"https://cdn.example.com/external.jpg"},
+ },
+ {
+ name: "multiple external URLs",
+ content: `{"elements":[{"tag":"img","src":"https://a.com/1.png"},{"tag":"img","src":"http://b.com/2.jpg"}]}`,
+ wantFeishuKeys: nil,
+ wantExternalURLs: []string{"https://a.com/1.png", "http://b.com/2.jpg"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotFeishuKeys, gotExternalURLs := extractCardImageKeys(tt.content)
+
+ // Compare Feishu keys
+ if len(gotFeishuKeys) != len(tt.wantFeishuKeys) {
+ t.Errorf("extractCardImageKeys() feishuKeys = %v, want %v", gotFeishuKeys, tt.wantFeishuKeys)
+ return
+ }
+ for i, v := range gotFeishuKeys {
+ if v != tt.wantFeishuKeys[i] {
+ t.Errorf("extractCardImageKeys() feishuKeys[%d] = %q, want %q", i, v, tt.wantFeishuKeys[i])
+ }
+ }
+
+ // Compare external URLs
+ if len(gotExternalURLs) != len(tt.wantExternalURLs) {
+ t.Errorf("extractCardImageKeys() externalURLs = %v, want %v", gotExternalURLs, tt.wantExternalURLs)
+ return
+ }
+ for i, v := range gotExternalURLs {
+ if v != tt.wantExternalURLs[i] {
+ t.Errorf("extractCardImageKeys() externalURLs[%d] = %q, want %q", i, v, tt.wantExternalURLs[i])
+ }
+ }
+ })
+ }
+}
diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go
index 0341efc70..37a74718a 100644
--- a/pkg/channels/feishu/feishu_64.go
+++ b/pkg/channels/feishu/feishu_64.go
@@ -424,6 +424,15 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
mediaRefs = c.downloadInboundMedia(ctx, chatID, messageID, messageType, rawContent, store)
}
+ // For interactive cards, pass external image URLs via media refs.
+ // Keep content as valid raw JSON for downstream parsing.
+ if messageType == larkim.MsgTypeInteractive {
+ _, externalURLs := extractCardImageKeys(rawContent)
+ if len(externalURLs) > 0 {
+ mediaRefs = append(mediaRefs, externalURLs...)
+ }
+ }
+
// Append media tags to content (like Telegram does)
content = appendMediaTags(content, messageType, mediaRefs)
@@ -559,6 +568,10 @@ func extractContent(messageType, rawContent string) string {
// Pass raw JSON to LLM — structured rich text is more informative than flattened plain text
return rawContent
+ case larkim.MsgTypeInteractive:
+ // Pass raw JSON to LLM — structured card is more informative than flattened text
+ return rawContent
+
case larkim.MsgTypeImage:
// Image messages don't have text content
return ""
@@ -596,6 +609,18 @@ func (c *FeishuChannel) downloadInboundMedia(
refs = append(refs, ref)
}
+ case larkim.MsgTypeInteractive:
+ // Extract and download images embedded in interactive cards
+ feishuKeys, _ := extractCardImageKeys(rawContent)
+ // Download Feishu-hosted images via API
+ for _, imageKey := range feishuKeys {
+ ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope)
+ if ref != "" {
+ refs = append(refs, ref)
+ }
+ }
+ // External URLs are passed directly to LLM, not downloaded
+
case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia:
fileKey := extractFileKey(rawContent)
if fileKey == "" {
@@ -716,11 +741,18 @@ func (c *FeishuChannel) downloadResource(
}
// appendMediaTags appends media type tags to content (like Telegram's "[image: photo]").
+// For interactive cards, media tags are not appended because content is raw JSON
+// and appending would produce invalid JSON format.
func appendMediaTags(content, messageType string, mediaRefs []string) string {
if len(mediaRefs) == 0 {
return content
}
+ // Don't append tags to JSON content (interactive cards) - would produce invalid JSON
+ if messageType == larkim.MsgTypeInteractive {
+ return content
+ }
+
var tag string
switch messageType {
case larkim.MsgTypeImage:
diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go
index dc3eab2e7..9010abf69 100644
--- a/pkg/channels/feishu/feishu_64_test.go
+++ b/pkg/channels/feishu/feishu_64_test.go
@@ -75,6 +75,24 @@ func TestExtractContent(t *testing.T) {
rawContent: "",
want: "",
},
+ {
+ name: "interactive card returns raw JSON",
+ messageType: "interactive",
+ rawContent: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"Hello from card"}]}}`,
+ want: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"Hello from card"}]}}`,
+ },
+ {
+ name: "interactive card with complex structure returns raw JSON",
+ messageType: "interactive",
+ rawContent: `{"header":{"title":{"tag":"plain_text","content":"Title"}},"elements":[{"tag":"div","text":{"tag":"lark_md","content":"Card content"}}]}`,
+ want: `{"header":{"title":{"tag":"plain_text","content":"Title"}},"elements":[{"tag":"div","text":{"tag":"lark_md","content":"Card content"}}]}`,
+ },
+ {
+ name: "interactive card invalid JSON returns as-is",
+ messageType: "interactive",
+ rawContent: `not valid json`,
+ want: `not valid json`,
+ },
}
for _, tt := range tests {
@@ -151,6 +169,13 @@ func TestAppendMediaTags(t *testing.T) {
mediaRefs: []string{"ref1"},
want: "something [attachment]",
},
+ {
+ name: "interactive card with images returns content unchanged",
+ content: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`,
+ messageType: "interactive",
+ mediaRefs: []string{"ref1"},
+ want: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`,
+ },
}
for _, tt := range tests {
From 1fd6dd1ffbca5c79da0fb7680b5147a29f914c84 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 20 Mar 2026 13:35:48 +0800
Subject: [PATCH 22/44] chore(deps): bump shadcn from 4.0.5 to 4.0.8 in
/web/frontend (#1808)
Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.0.5 to 4.0.8.
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.0.8/packages/shadcn)
---
updated-dependencies:
- dependency-name: shadcn
dependency-version: 4.0.8
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>
---
web/frontend/package.json | 2 +-
web/frontend/pnpm-lock.yaml | 217 +++++++++++++++++-------------------
2 files changed, 105 insertions(+), 114 deletions(-)
diff --git a/web/frontend/package.json b/web/frontend/package.json
index e20c28011..2a00157e6 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -32,7 +32,7 @@
"react-markdown": "^10.1.0",
"react-textarea-autosize": "^8.5.9",
"remark-gfm": "^4.0.1",
- "shadcn": "^4.0.5",
+ "shadcn": "^4.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml
index ee047194d..807c1a982 100644
--- a/web/frontend/pnpm-lock.yaml
+++ b/web/frontend/pnpm-lock.yaml
@@ -66,8 +66,8 @@ importers:
specifier: ^4.0.1
version: 4.0.1
shadcn:
- specifier: ^4.0.5
- version: 4.0.5(@types/node@25.5.0)(typescript@5.9.3)
+ specifier: ^4.1.0
+ version: 4.1.0(@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)
@@ -144,10 +144,6 @@ importers:
packages:
- '@antfu/ni@25.0.0':
- resolution: {integrity: sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==}
- hasBin: true
-
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@@ -226,8 +222,8 @@ packages:
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
engines: {node: '>=6.9.0'}
- '@babel/helpers@7.28.6':
- resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==}
+ '@babel/helpers@7.29.2':
+ resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.0':
@@ -235,6 +231,11 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ '@babel/parser@7.29.2':
+ resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
'@babel/plugin-syntax-jsx@7.28.6':
resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==}
engines: {node: '>=6.9.0'}
@@ -293,8 +294,8 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
- '@dotenvx/dotenvx@1.52.0':
- resolution: {integrity: sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w==}
+ '@dotenvx/dotenvx@1.57.0':
+ resolution: {integrity: sha512-WsTEcqfHzKmLFZh3jLGd7o4iCkrIupp+qFH2FJUJtQXUh2GcOnLXD00DcrhlO4H8QSmaKnW9lugOEbrdpu25kA==}
hasBin: true
'@ecies/ciphers@0.2.5':
@@ -1935,8 +1936,8 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
- baseline-browser-mapping@2.10.0:
- resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==}
+ baseline-browser-mapping@2.10.9:
+ resolution: {integrity: sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -1987,8 +1988,8 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
- caniuse-lite@1.0.30001775:
- resolution: {integrity: sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==}
+ caniuse-lite@1.0.30001780:
+ resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -2094,8 +2095,8 @@ packages:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
- cosmiconfig@9.0.0:
- resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==}
+ cosmiconfig@9.0.1:
+ resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==}
engines: {node: '>=14'}
peerDependencies:
typescript: '>=4.9.5'
@@ -2191,15 +2192,15 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
- eciesjs@0.4.17:
- resolution: {integrity: sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w==}
+ eciesjs@0.4.18:
+ resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==}
engines: {bun: '>=1', deno: '>=2', node: '>=16'}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
- electron-to-chromium@1.5.302:
- resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==}
+ electron-to-chromium@1.5.321:
+ resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -2429,8 +2430,8 @@ packages:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
- fs-extra@11.3.3:
- resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==}
+ fs-extra@11.3.4:
+ resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
engines: {node: '>=14.14'}
fsevents@2.3.3:
@@ -2444,9 +2445,6 @@ packages:
fuzzysort@3.1.0:
resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==}
- fzf@0.5.2:
- resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==}
-
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
@@ -2514,8 +2512,8 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
- graphql@16.13.0:
- resolution: {integrity: sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA==}
+ graphql@16.13.1:
+ resolution: {integrity: sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
has-flag@4.0.0:
@@ -2545,8 +2543,8 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
- hono@4.12.7:
- resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==}
+ hono@4.12.8:
+ resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==}
engines: {node: '>=16.9.0'}
html-parse-stringify@3.0.1:
@@ -2725,8 +2723,8 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
- jose@6.1.3:
- resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
+ jose@6.2.2:
+ resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
jotai@2.18.1:
resolution: {integrity: sha512-e0NOzK+yRFwHo7DOp0DS0Ycq74KMEAObDWFGmfEL28PD9nLqBTt3/Ug7jf9ca72x0gC9LQZG9zH+0ISICmy3iA==}
@@ -3083,8 +3081,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
- msw@2.12.10:
- resolution: {integrity: sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==}
+ msw@2.12.13:
+ resolution: {integrity: sha512-9CV2mXT9+z0J26MQDfEZZkj/psJ5Er/w0w+t95FWdaGH/DTlhNZBx8vBO5jSYv8AZEnl3ouX+AaTT68KXdAIag==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
@@ -3118,8 +3116,8 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
- node-releases@2.0.27:
- resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
+ node-releases@2.0.36:
+ resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
@@ -3183,9 +3181,6 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
- package-manager-detector@1.6.0:
- resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
-
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -3262,6 +3257,10 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
+ postcss@8.5.8:
+ resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
+ engines: {node: ^10 || ^12 || >=14}
+
powershell-utils@0.1.0:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
@@ -3556,8 +3555,8 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
- shadcn@4.0.5:
- resolution: {integrity: sha512-z0SOHEU1+ADam1UJHrgxJhUsOb0/jBoYc+u9mhWs071KrnORq48X7uCwG3mD2ysQEBtOfeK/MxMGsmzL5Jt+Jg==}
+ shadcn@4.1.0:
+ resolution: {integrity: sha512-3zETJ+0Ezj69FS6RL0HOkLKKAR5yXisXx1iISJdfLQfrUqj/VIQlanQi1Ukk+9OE+XHZVj4FQNTBSfbr2CyCYg==}
hasBin: true
shebang-command@2.0.0:
@@ -3699,19 +3698,15 @@ packages:
tiny-warning@1.0.3:
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
- tinyexec@1.0.2:
- resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
- engines: {node: '>=18'}
-
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
- tldts-core@7.0.23:
- resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==}
+ tldts-core@7.0.26:
+ resolution: {integrity: sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==}
- tldts@7.0.23:
- resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==}
+ tldts@7.0.26:
+ resolution: {integrity: sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==}
hasBin: true
to-regex-range@5.0.1:
@@ -3722,8 +3717,8 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
- tough-cookie@6.0.0:
- resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
+ tough-cookie@6.0.1:
+ resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
engines: {node: '>=16'}
trim-lines@3.0.1:
@@ -3766,8 +3761,8 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
- type-fest@5.4.4:
- resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==}
+ type-fest@5.5.0:
+ resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==}
engines: {node: '>=20'}
type-is@2.0.1:
@@ -4037,13 +4032,6 @@ packages:
snapshots:
- '@antfu/ni@25.0.0':
- dependencies:
- ansis: 4.2.0
- fzf: 0.5.2
- package-manager-detector: 1.6.0
- tinyexec: 1.0.2
-
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -4058,8 +4046,8 @@ snapshots:
'@babel/generator': 7.29.1
'@babel/helper-compilation-targets': 7.28.6
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
- '@babel/helpers': 7.28.6
- '@babel/parser': 7.29.0
+ '@babel/helpers': 7.29.2
+ '@babel/parser': 7.29.2
'@babel/template': 7.28.6
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
@@ -4158,7 +4146,7 @@ snapshots:
'@babel/helper-validator-option@7.27.1': {}
- '@babel/helpers@7.28.6':
+ '@babel/helpers@7.29.2':
dependencies:
'@babel/template': 7.28.6
'@babel/types': 7.29.0
@@ -4167,6 +4155,10 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
+ '@babel/parser@7.29.2':
+ dependencies:
+ '@babel/types': 7.29.0
+
'@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
@@ -4222,7 +4214,7 @@ snapshots:
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
- '@babel/parser': 7.29.0
+ '@babel/parser': 7.29.2
'@babel/types': 7.29.0
'@babel/traverse@7.29.0':
@@ -4230,7 +4222,7 @@ snapshots:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/helper-globals': 7.28.0
- '@babel/parser': 7.29.0
+ '@babel/parser': 7.29.2
'@babel/template': 7.28.6
'@babel/types': 7.29.0
debug: 4.4.3
@@ -4242,11 +4234,11 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
- '@dotenvx/dotenvx@1.52.0':
+ '@dotenvx/dotenvx@1.57.0':
dependencies:
commander: 11.1.0
dotenv: 17.3.1
- eciesjs: 0.4.17
+ eciesjs: 0.4.18
execa: 5.1.1
fdir: 6.5.0(picomatch@4.0.3)
ignore: 5.3.2
@@ -4401,9 +4393,9 @@ snapshots:
'@fontsource-variable/inter@5.2.8': {}
- '@hono/node-server@1.19.11(hono@4.12.7)':
+ '@hono/node-server@1.19.11(hono@4.12.8)':
dependencies:
- hono: 4.12.7
+ hono: 4.12.8
'@humanfs/core@0.19.1': {}
@@ -4465,7 +4457,7 @@ snapshots:
'@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)':
dependencies:
- '@hono/node-server': 1.19.11(hono@4.12.7)
+ '@hono/node-server': 1.19.11(hono@4.12.8)
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
content-type: 1.0.5
@@ -4475,8 +4467,8 @@ snapshots:
eventsource-parser: 3.0.6
express: 5.2.1
express-rate-limit: 8.3.1(express@5.2.1)
- hono: 4.12.7
- jose: 6.1.3
+ hono: 4.12.8
+ jose: 6.2.2
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
raw-body: 3.0.2
@@ -5536,7 +5528,7 @@ snapshots:
dependencies:
'@babel/core': 7.29.0
'@babel/generator': 7.29.1
- '@babel/parser': 7.29.0
+ '@babel/parser': 7.29.2
'@babel/types': 7.29.0
ansis: 4.2.0
babel-dead-code-elimination: 1.0.12
@@ -5574,7 +5566,7 @@ snapshots:
'@types/babel__core@7.20.5':
dependencies:
- '@babel/parser': 7.29.0
+ '@babel/parser': 7.29.2
'@babel/types': 7.29.0
'@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4
@@ -5586,7 +5578,7 @@ snapshots:
'@types/babel__template@7.4.4':
dependencies:
- '@babel/parser': 7.29.0
+ '@babel/parser': 7.29.2
'@babel/types': 7.29.0
'@types/babel__traverse@7.28.0':
@@ -5880,7 +5872,7 @@ snapshots:
babel-dead-code-elimination@1.0.12:
dependencies:
'@babel/core': 7.29.0
- '@babel/parser': 7.29.0
+ '@babel/parser': 7.29.2
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
transitivePeerDependencies:
@@ -5892,7 +5884,7 @@ snapshots:
balanced-match@4.0.4: {}
- baseline-browser-mapping@2.10.0: {}
+ baseline-browser-mapping@2.10.9: {}
binary-extensions@2.3.0: {}
@@ -5929,10 +5921,10 @@ snapshots:
browserslist@4.28.1:
dependencies:
- baseline-browser-mapping: 2.10.0
- caniuse-lite: 1.0.30001775
- electron-to-chromium: 1.5.302
- node-releases: 2.0.27
+ baseline-browser-mapping: 2.10.9
+ caniuse-lite: 1.0.30001780
+ electron-to-chromium: 1.5.321
+ node-releases: 2.0.36
update-browserslist-db: 1.2.3(browserslist@4.28.1)
bundle-name@4.1.0:
@@ -5953,7 +5945,7 @@ snapshots:
callsites@3.1.0: {}
- caniuse-lite@1.0.30001775: {}
+ caniuse-lite@1.0.30001780: {}
ccount@2.0.1: {}
@@ -6039,7 +6031,7 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
- cosmiconfig@9.0.0(typescript@5.9.3):
+ cosmiconfig@9.0.1(typescript@5.9.3):
dependencies:
env-paths: 2.2.1
import-fresh: 3.3.1
@@ -6107,7 +6099,7 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
- eciesjs@0.4.17:
+ eciesjs@0.4.18:
dependencies:
'@ecies/ciphers': 0.2.5(@noble/ciphers@1.3.0)
'@noble/ciphers': 1.3.0
@@ -6116,7 +6108,7 @@ snapshots:
ee-first@1.1.1: {}
- electron-to-chromium@1.5.302: {}
+ electron-to-chromium@1.5.321: {}
emoji-regex@10.6.0: {}
@@ -6420,7 +6412,7 @@ snapshots:
fresh@2.0.0: {}
- fs-extra@11.3.3:
+ fs-extra@11.3.4:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.2.0
@@ -6433,8 +6425,6 @@ snapshots:
fuzzysort@3.1.0: {}
- fzf@0.5.2: {}
-
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
@@ -6494,7 +6484,7 @@ snapshots:
graceful-fs@4.2.11: {}
- graphql@16.13.0: {}
+ graphql@16.13.1: {}
has-flag@4.0.0: {}
@@ -6536,7 +6526,7 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
- hono@4.12.7: {}
+ hono@4.12.8: {}
html-parse-stringify@3.0.1:
dependencies:
@@ -6665,7 +6655,7 @@ snapshots:
jiti@2.6.1: {}
- jose@6.1.3: {}
+ 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):
optionalDependencies:
@@ -7176,14 +7166,14 @@ snapshots:
ms@2.1.3: {}
- msw@2.12.10(@types/node@25.5.0)(typescript@5.9.3):
+ msw@2.12.13(@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.0
+ graphql: 16.13.1
headers-polyfill: 4.0.3
is-node-process: 1.2.0
outvariant: 1.4.3
@@ -7192,8 +7182,8 @@ snapshots:
rettime: 0.10.1
statuses: 2.0.2
strict-event-emitter: 0.5.1
- tough-cookie: 6.0.0
- type-fest: 5.4.4
+ tough-cookie: 6.0.1
+ type-fest: 5.5.0
until-async: 3.0.2
yargs: 17.7.2
optionalDependencies:
@@ -7217,7 +7207,7 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
- node-releases@2.0.27: {}
+ node-releases@2.0.36: {}
normalize-path@3.0.0: {}
@@ -7292,8 +7282,6 @@ snapshots:
dependencies:
p-limit: 3.1.0
- package-manager-detector@1.6.0: {}
-
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -7363,6 +7351,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ postcss@8.5.8:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
powershell-utils@0.1.0: {}
prelude-ls@1.2.1: {}
@@ -7702,33 +7696,32 @@ snapshots:
setprototypeof@1.2.0: {}
- shadcn@4.0.5(@types/node@25.5.0)(typescript@5.9.3):
+ shadcn@4.1.0(@types/node@25.5.0)(typescript@5.9.3):
dependencies:
- '@antfu/ni': 25.0.0
'@babel/core': 7.29.0
- '@babel/parser': 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.52.0
+ '@dotenvx/dotenvx': 1.57.0
'@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76)
'@types/validate-npm-package-name': 4.0.2
browserslist: 4.28.1
commander: 14.0.3
- cosmiconfig: 9.0.0(typescript@5.9.3)
+ cosmiconfig: 9.0.1(typescript@5.9.3)
dedent: 1.7.2
deepmerge: 4.3.1
diff: 8.0.3
execa: 9.6.1
fast-glob: 3.3.3
- fs-extra: 11.3.3
+ fs-extra: 11.3.4
fuzzysort: 3.1.0
https-proxy-agent: 7.0.6
kleur: 4.1.5
- msw: 2.12.10(@types/node@25.5.0)(typescript@5.9.3)
+ msw: 2.12.13(@types/node@25.5.0)(typescript@5.9.3)
node-fetch: 3.3.2
open: 11.0.0
ora: 8.2.0
- postcss: 8.5.6
+ postcss: 8.5.8
postcss-selector-parser: 7.1.1
prompts: 2.4.2
recast: 0.23.11
@@ -7873,18 +7866,16 @@ snapshots:
tiny-warning@1.0.3: {}
- tinyexec@1.0.2: {}
-
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
- tldts-core@7.0.23: {}
+ tldts-core@7.0.26: {}
- tldts@7.0.23:
+ tldts@7.0.26:
dependencies:
- tldts-core: 7.0.23
+ tldts-core: 7.0.26
to-regex-range@5.0.1:
dependencies:
@@ -7892,9 +7883,9 @@ snapshots:
toidentifier@1.0.1: {}
- tough-cookie@6.0.0:
+ tough-cookie@6.0.1:
dependencies:
- tldts: 7.0.23
+ tldts: 7.0.26
trim-lines@3.0.1: {}
@@ -7934,7 +7925,7 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
- type-fest@5.4.4:
+ type-fest@5.5.0:
dependencies:
tagged-tag: 1.0.0
From cff85cfe5cb818099b9cbe2d1362c2f668952760 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 20 Mar 2026 13:53:31 +0800
Subject: [PATCH 23/44] chore(deps): bump tailwindcss from 4.2.1 to 4.2.2 in
/web/frontend (#1809)
* chore(deps): bump tailwindcss from 4.2.1 to 4.2.2 in /web/frontend
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 4.2.1 to 4.2.2.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/tailwindcss)
---
updated-dependencies:
- dependency-name: tailwindcss
dependency-version: 4.2.2
dependency-type: direct:production
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]
* fix(frontend): align tailwind vite deps
---------
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: wenjie
---
web/frontend/package.json | 4 +-
web/frontend/pnpm-lock.yaml | 260 ++++++++++++++++++------------------
2 files changed, 132 insertions(+), 132 deletions(-)
diff --git a/web/frontend/package.json b/web/frontend/package.json
index 2a00157e6..ecb7552a5 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -15,7 +15,7 @@
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@tabler/icons-react": "^3.40.0",
- "@tailwindcss/vite": "^4.2.1",
+ "@tailwindcss/vite": "^4.2.2",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.167.0",
"@tanstack/react-router-devtools": "^1.163.3",
@@ -35,7 +35,7 @@
"shadcn": "^4.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
- "tailwindcss": "^4.2.1",
+ "tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"wrap-ansi": "^10.0.0"
},
diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml
index 807c1a982..3ef12c088 100644
--- a/web/frontend/pnpm-lock.yaml
+++ b/web/frontend/pnpm-lock.yaml
@@ -15,8 +15,8 @@ importers:
specifier: ^3.40.0
version: 3.40.0(react@19.2.4)
'@tailwindcss/vite':
- specifier: ^4.2.1
- version: 4.2.1(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
+ 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))
'@tanstack/react-query':
specifier: ^5.90.21
version: 5.90.21(react@19.2.4)
@@ -75,8 +75,8 @@ importers:
specifier: ^3.5.0
version: 3.5.0
tailwindcss:
- specifier: ^4.2.1
- version: 4.2.1
+ specifier: ^4.2.2
+ version: 4.2.2
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
@@ -89,10 +89,10 @@ importers:
version: 9.39.3
'@tailwindcss/typography':
specifier: ^0.5.19
- version: 0.5.19(tailwindcss@4.2.1)
+ version: 0.5.19(tailwindcss@4.2.2)
'@tanstack/router-plugin':
specifier: ^1.164.0
- version: 1.164.0(@tanstack/react-router@1.167.0(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.31.1)(tsx@4.21.0))
+ version: 1.164.0(@tanstack/react-router@1.167.0(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))
'@trivago/prettier-plugin-sort-imports':
specifier: ^6.0.2
version: 6.0.2(prettier@3.8.1)
@@ -110,7 +110,7 @@ importers:
version: 8.56.1(@typescript-eslint/parser@8.57.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(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.31.1)(tsx@4.21.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))
eslint:
specifier: ^9.39.3
version: 9.39.3(jiti@2.6.1)
@@ -140,7 +140,7 @@ importers:
version: 8.57.1(eslint@9.39.3(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.31.1)(tsx@4.21.0)
+ version: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)
packages:
@@ -1469,65 +1469,65 @@ packages:
'@tabler/icons@3.40.0':
resolution: {integrity: sha512-V/Q4VgNPKubRTiLdmWjV/zscYcj5IIk+euicUtaVVqF6luSC9rDngYWgST5/yh3Mrg/mYUwRv1YVTk71Jp0twQ==}
- '@tailwindcss/node@4.2.1':
- resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
+ '@tailwindcss/node@4.2.2':
+ resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
- '@tailwindcss/oxide-android-arm64@4.2.1':
- resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==}
+ '@tailwindcss/oxide-android-arm64@4.2.2':
+ resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [android]
- '@tailwindcss/oxide-darwin-arm64@4.2.1':
- resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==}
+ '@tailwindcss/oxide-darwin-arm64@4.2.2':
+ resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [darwin]
- '@tailwindcss/oxide-darwin-x64@4.2.1':
- resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==}
+ '@tailwindcss/oxide-darwin-x64@4.2.2':
+ resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [darwin]
- '@tailwindcss/oxide-freebsd-x64@4.2.1':
- resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==}
+ '@tailwindcss/oxide-freebsd-x64@4.2.2':
+ resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [freebsd]
- '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
- resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==}
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
+ resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==}
engines: {node: '>= 20'}
cpu: [arm]
os: [linux]
- '@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
- resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==}
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
+ resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
- '@tailwindcss/oxide-linux-arm64-musl@4.2.1':
- resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.2':
+ resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
- '@tailwindcss/oxide-linux-x64-gnu@4.2.1':
- resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.2':
+ resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
- '@tailwindcss/oxide-linux-x64-musl@4.2.1':
- resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
+ '@tailwindcss/oxide-linux-x64-musl@4.2.2':
+ resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
- '@tailwindcss/oxide-wasm32-wasi@4.2.1':
- resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
+ '@tailwindcss/oxide-wasm32-wasi@4.2.2':
+ resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
@@ -1538,20 +1538,20 @@ packages:
- '@emnapi/wasi-threads'
- tslib
- '@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
- resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==}
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
+ resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [win32]
- '@tailwindcss/oxide-win32-x64-msvc@4.2.1':
- resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==}
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.2':
+ resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [win32]
- '@tailwindcss/oxide@4.2.1':
- resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==}
+ '@tailwindcss/oxide@4.2.2':
+ resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==}
engines: {node: '>= 20'}
'@tailwindcss/typography@0.5.19':
@@ -1559,10 +1559,10 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
- '@tailwindcss/vite@4.2.1':
- resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==}
+ '@tailwindcss/vite@4.2.2':
+ resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==}
peerDependencies:
- vite: ^5.2.0 || ^6 || ^7
+ vite: ^5.2.0 || ^6 || ^7 || ^8
'@tanstack/history@1.161.4':
resolution: {integrity: sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww==}
@@ -2797,74 +2797,74 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
- lightningcss-android-arm64@1.31.1:
- resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
+ lightningcss-android-arm64@1.32.0:
+ resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
- lightningcss-darwin-arm64@1.31.1:
- resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==}
+ lightningcss-darwin-arm64@1.32.0:
+ resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
- lightningcss-darwin-x64@1.31.1:
- resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==}
+ lightningcss-darwin-x64@1.32.0:
+ resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
- lightningcss-freebsd-x64@1.31.1:
- resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==}
+ lightningcss-freebsd-x64@1.32.0:
+ resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
- lightningcss-linux-arm-gnueabihf@1.31.1:
- resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==}
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
- lightningcss-linux-arm64-gnu@1.31.1:
- resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==}
+ lightningcss-linux-arm64-gnu@1.32.0:
+ resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- lightningcss-linux-arm64-musl@1.31.1:
- resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
+ lightningcss-linux-arm64-musl@1.32.0:
+ resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- lightningcss-linux-x64-gnu@1.31.1:
- resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
+ lightningcss-linux-x64-gnu@1.32.0:
+ resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- lightningcss-linux-x64-musl@1.31.1:
- resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
+ lightningcss-linux-x64-musl@1.32.0:
+ resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- lightningcss-win32-arm64-msvc@1.31.1:
- resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
+ lightningcss-win32-arm64-msvc@1.32.0:
+ resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
- lightningcss-win32-x64-msvc@1.31.1:
- resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==}
+ lightningcss-win32-x64-msvc@1.32.0:
+ resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
- lightningcss@1.31.1:
- resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==}
+ lightningcss@1.32.0:
+ resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
lines-and-columns@1.2.4:
@@ -3685,8 +3685,8 @@ packages:
tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
- tailwindcss@4.2.1:
- resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==}
+ tailwindcss@4.2.2:
+ resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
tapable@2.3.0:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
@@ -5350,78 +5350,78 @@ snapshots:
'@tabler/icons@3.40.0': {}
- '@tailwindcss/node@4.2.1':
+ '@tailwindcss/node@4.2.2':
dependencies:
'@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.20.0
jiti: 2.6.1
- lightningcss: 1.31.1
+ lightningcss: 1.32.0
magic-string: 0.30.21
source-map-js: 1.2.1
- tailwindcss: 4.2.1
+ tailwindcss: 4.2.2
- '@tailwindcss/oxide-android-arm64@4.2.1':
+ '@tailwindcss/oxide-android-arm64@4.2.2':
optional: true
- '@tailwindcss/oxide-darwin-arm64@4.2.1':
+ '@tailwindcss/oxide-darwin-arm64@4.2.2':
optional: true
- '@tailwindcss/oxide-darwin-x64@4.2.1':
+ '@tailwindcss/oxide-darwin-x64@4.2.2':
optional: true
- '@tailwindcss/oxide-freebsd-x64@4.2.1':
+ '@tailwindcss/oxide-freebsd-x64@4.2.2':
optional: true
- '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
optional: true
- '@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
optional: true
- '@tailwindcss/oxide-linux-arm64-musl@4.2.1':
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.2':
optional: true
- '@tailwindcss/oxide-linux-x64-gnu@4.2.1':
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.2':
optional: true
- '@tailwindcss/oxide-linux-x64-musl@4.2.1':
+ '@tailwindcss/oxide-linux-x64-musl@4.2.2':
optional: true
- '@tailwindcss/oxide-wasm32-wasi@4.2.1':
+ '@tailwindcss/oxide-wasm32-wasi@4.2.2':
optional: true
- '@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
optional: true
- '@tailwindcss/oxide-win32-x64-msvc@4.2.1':
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.2':
optional: true
- '@tailwindcss/oxide@4.2.1':
+ '@tailwindcss/oxide@4.2.2':
optionalDependencies:
- '@tailwindcss/oxide-android-arm64': 4.2.1
- '@tailwindcss/oxide-darwin-arm64': 4.2.1
- '@tailwindcss/oxide-darwin-x64': 4.2.1
- '@tailwindcss/oxide-freebsd-x64': 4.2.1
- '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1
- '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1
- '@tailwindcss/oxide-linux-arm64-musl': 4.2.1
- '@tailwindcss/oxide-linux-x64-gnu': 4.2.1
- '@tailwindcss/oxide-linux-x64-musl': 4.2.1
- '@tailwindcss/oxide-wasm32-wasi': 4.2.1
- '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1
- '@tailwindcss/oxide-win32-x64-msvc': 4.2.1
+ '@tailwindcss/oxide-android-arm64': 4.2.2
+ '@tailwindcss/oxide-darwin-arm64': 4.2.2
+ '@tailwindcss/oxide-darwin-x64': 4.2.2
+ '@tailwindcss/oxide-freebsd-x64': 4.2.2
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2
+ '@tailwindcss/oxide-linux-arm64-musl': 4.2.2
+ '@tailwindcss/oxide-linux-x64-gnu': 4.2.2
+ '@tailwindcss/oxide-linux-x64-musl': 4.2.2
+ '@tailwindcss/oxide-wasm32-wasi': 4.2.2
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
+ '@tailwindcss/oxide-win32-x64-msvc': 4.2.2
- '@tailwindcss/typography@0.5.19(tailwindcss@4.2.1)':
+ '@tailwindcss/typography@0.5.19(tailwindcss@4.2.2)':
dependencies:
postcss-selector-parser: 6.0.10
- tailwindcss: 4.2.1
+ tailwindcss: 4.2.2
- '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))':
+ '@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))':
dependencies:
- '@tailwindcss/node': 4.2.1
- '@tailwindcss/oxide': 4.2.1
- tailwindcss: 4.2.1
- vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)
+ '@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)
'@tanstack/history@1.161.4': {}
@@ -5503,7 +5503,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@tanstack/router-plugin@1.164.0(@tanstack/react-router@1.167.0(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.31.1)(tsx@4.21.0))':
+ '@tanstack/router-plugin@1.164.0(@tanstack/react-router@1.167.0(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))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
@@ -5520,7 +5520,7 @@ snapshots:
zod: 3.25.76
optionalDependencies:
'@tanstack/react-router': 1.167.0(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.31.1)(tsx@4.21.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
@@ -5799,7 +5799,7 @@ snapshots:
'@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.31.1)(tsx@4.21.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))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
@@ -5807,7 +5807,7 @@ snapshots:
'@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.31.1)(tsx@4.21.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
@@ -6705,54 +6705,54 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
- lightningcss-android-arm64@1.31.1:
+ lightningcss-android-arm64@1.32.0:
optional: true
- lightningcss-darwin-arm64@1.31.1:
+ lightningcss-darwin-arm64@1.32.0:
optional: true
- lightningcss-darwin-x64@1.31.1:
+ lightningcss-darwin-x64@1.32.0:
optional: true
- lightningcss-freebsd-x64@1.31.1:
+ lightningcss-freebsd-x64@1.32.0:
optional: true
- lightningcss-linux-arm-gnueabihf@1.31.1:
+ lightningcss-linux-arm-gnueabihf@1.32.0:
optional: true
- lightningcss-linux-arm64-gnu@1.31.1:
+ lightningcss-linux-arm64-gnu@1.32.0:
optional: true
- lightningcss-linux-arm64-musl@1.31.1:
+ lightningcss-linux-arm64-musl@1.32.0:
optional: true
- lightningcss-linux-x64-gnu@1.31.1:
+ lightningcss-linux-x64-gnu@1.32.0:
optional: true
- lightningcss-linux-x64-musl@1.31.1:
+ lightningcss-linux-x64-musl@1.32.0:
optional: true
- lightningcss-win32-arm64-msvc@1.31.1:
+ lightningcss-win32-arm64-msvc@1.32.0:
optional: true
- lightningcss-win32-x64-msvc@1.31.1:
+ lightningcss-win32-x64-msvc@1.32.0:
optional: true
- lightningcss@1.31.1:
+ lightningcss@1.32.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
- lightningcss-android-arm64: 1.31.1
- lightningcss-darwin-arm64: 1.31.1
- lightningcss-darwin-x64: 1.31.1
- lightningcss-freebsd-x64: 1.31.1
- lightningcss-linux-arm-gnueabihf: 1.31.1
- lightningcss-linux-arm64-gnu: 1.31.1
- lightningcss-linux-arm64-musl: 1.31.1
- lightningcss-linux-x64-gnu: 1.31.1
- lightningcss-linux-x64-musl: 1.31.1
- lightningcss-win32-arm64-msvc: 1.31.1
- lightningcss-win32-x64-msvc: 1.31.1
+ lightningcss-android-arm64: 1.32.0
+ lightningcss-darwin-arm64: 1.32.0
+ lightningcss-darwin-x64: 1.32.0
+ lightningcss-freebsd-x64: 1.32.0
+ lightningcss-linux-arm-gnueabihf: 1.32.0
+ lightningcss-linux-arm64-gnu: 1.32.0
+ lightningcss-linux-arm64-musl: 1.32.0
+ lightningcss-linux-x64-gnu: 1.32.0
+ lightningcss-linux-x64-musl: 1.32.0
+ lightningcss-win32-arm64-msvc: 1.32.0
+ lightningcss-win32-x64-msvc: 1.32.0
lines-and-columns@1.2.4: {}
@@ -7858,7 +7858,7 @@ snapshots:
tailwind-merge@3.5.0: {}
- tailwindcss@4.2.1: {}
+ tailwindcss@4.2.2: {}
tapable@2.3.0: {}
@@ -8062,7 +8062,7 @@ 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.31.1)(tsx@4.21.0):
+ vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0):
dependencies:
esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3)
@@ -8074,7 +8074,7 @@ snapshots:
'@types/node': 25.5.0
fsevents: 2.3.3
jiti: 2.6.1
- lightningcss: 1.31.1
+ lightningcss: 1.32.0
tsx: 4.21.0
void-elements@3.1.0: {}
From 82d574eb7b85c16d97a1697b3224505d72a7c49d Mon Sep 17 00:00:00 2001
From: Alix-007 <267018309+Alix-007@users.noreply.github.com>
Date: Fri, 20 Mar 2026 14:37:47 +0800
Subject: [PATCH 24/44] fix(agent): separate empty-response and tool-limit
fallbacks
---
pkg/agent/loop.go | 9 ++-
pkg/agent/loop_test.go | 129 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 136 insertions(+), 2 deletions(-)
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index ef2b9e28f..637cd506c 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -70,7 +70,8 @@ type processOptions struct {
}
const (
- defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
+ defaultResponse = "The model returned an empty response. This may indicate a provider error or token limit."
+ toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps."
sessionKeyAgentPrefix = "agent:"
metadataKeyAccountID = "account_id"
metadataKeyGuildID = "guild_id"
@@ -935,7 +936,11 @@ func (al *AgentLoop) runAgentLoop(
// 4. Handle empty response
if finalContent == "" {
- finalContent = opts.DefaultResponse
+ if iteration >= agent.MaxIterations && agent.MaxIterations > 0 {
+ finalContent = toolLimitResponse
+ } else {
+ finalContent = opts.DefaultResponse
+ }
}
// 5. Save final assistant message to session
diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go
index b6b6c2c6c..28eab03db 100644
--- a/pkg/agent/loop_test.go
+++ b/pkg/agent/loop_test.go
@@ -420,6 +420,29 @@ func (m *countingMockProvider) GetDefaultModel() string {
return "counting-mock-model"
}
+type toolLimitOnlyProvider struct{}
+
+func (m *toolLimitOnlyProvider) Chat(
+ ctx context.Context,
+ messages []providers.Message,
+ tools []providers.ToolDefinition,
+ model string,
+ opts map[string]any,
+) (*providers.LLMResponse, error) {
+ return &providers.LLMResponse{
+ ToolCalls: []providers.ToolCall{{
+ ID: "call_tool_limit_test",
+ Type: "function",
+ Name: "tool_limit_test_tool",
+ Arguments: map[string]any{"value": "x"},
+ }},
+ }, nil
+}
+
+func (m *toolLimitOnlyProvider) GetDefaultModel() string {
+ return "tool-limit-only-model"
+}
+
// mockCustomTool is a simple mock tool for registration testing
type mockCustomTool struct{}
@@ -442,6 +465,29 @@ func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tool
return tools.SilentResult("Custom tool executed")
}
+type toolLimitTestTool struct{}
+
+func (m *toolLimitTestTool) Name() string {
+ return "tool_limit_test_tool"
+}
+
+func (m *toolLimitTestTool) Description() string {
+ return "Tool used to exhaust the iteration budget in tests"
+}
+
+func (m *toolLimitTestTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "value": map[string]any{"type": "string"},
+ },
+ }
+}
+
+func (m *toolLimitTestTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
+ return tools.SilentResult("tool limit test result")
+}
+
// testHelper executes a message and returns the response
type testHelper struct {
al *AgentLoop
@@ -1083,6 +1129,89 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
}
}
+func TestAgentLoop_EmptyModelResponseUsesAccurateFallback(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "agent-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: tmpDir,
+ Model: "test-model",
+ MaxTokens: 4096,
+ MaxToolIterations: 3,
+ },
+ },
+ }
+
+ msgBus := bus.NewMessageBus()
+ provider := &simpleMockProvider{response: ""}
+ al := NewAgentLoop(cfg, msgBus, provider)
+
+ response, err := al.ProcessDirectWithChannel(context.Background(), "hello", "empty-response", "test", "chat1")
+ if err != nil {
+ t.Fatalf("ProcessDirectWithChannel failed: %v", err)
+ }
+ if response != defaultResponse {
+ t.Fatalf("response = %q, want %q", response, defaultResponse)
+ }
+}
+
+func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "agent-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: tmpDir,
+ Model: "test-model",
+ MaxTokens: 4096,
+ MaxToolIterations: 1,
+ },
+ },
+ }
+
+ msgBus := bus.NewMessageBus()
+ provider := &toolLimitOnlyProvider{}
+ al := NewAgentLoop(cfg, msgBus, provider)
+ al.RegisterTool(&toolLimitTestTool{})
+
+ response, err := al.ProcessDirectWithChannel(context.Background(), "hello", "tool-limit", "test", "chat1")
+ if err != nil {
+ t.Fatalf("ProcessDirectWithChannel failed: %v", err)
+ }
+ if response != toolLimitResponse {
+ t.Fatalf("response = %q, want %q", response, toolLimitResponse)
+ }
+
+ defaultAgent := al.registry.GetDefaultAgent()
+ if defaultAgent == nil {
+ t.Fatal("No default agent found")
+ }
+ route := al.registry.ResolveRoute(routing.RouteInput{
+ Channel: "test",
+ Peer: &routing.RoutePeer{
+ Kind: "direct",
+ ID: "cron",
+ },
+ })
+ history := defaultAgent.Sessions.GetHistory(route.SessionKey)
+ if len(history) != 4 {
+ t.Fatalf("history len = %d, want 4", len(history))
+ }
+ assertRoles(t, history, "user", "assistant", "tool", "assistant")
+ if history[3].Content != toolLimitResponse {
+ t.Fatalf("final assistant content = %q, want %q", history[3].Content, toolLimitResponse)
+ }
+}
+
// TestProcessDirectWithChannel_TriggersMCPInitialization verifies that
// ProcessDirectWithChannel triggers MCP initialization when MCP is enabled.
// Note: Manager is only initialized when at least one MCP server is configured
From bda18f5ee4d269dca524a5e3541223896514818a Mon Sep 17 00:00:00 2001
From: wenjie
Date: Fri, 20 Mar 2026 15:18:15 +0800
Subject: [PATCH 25/44] chore(deps): upgrade eslint dependency chain to resolve
flatted vulnerability (#1820)
---
web/frontend/package.json | 8 +-
web/frontend/pnpm-lock.yaml | 714 +++++++++++++-----------------------
2 files changed, 265 insertions(+), 457 deletions(-)
diff --git a/web/frontend/package.json b/web/frontend/package.json
index ecb7552a5..b1cc09b7b 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -40,19 +40,19 @@
"wrap-ansi": "^10.0.0"
},
"devDependencies": {
- "@eslint/js": "^9.39.3",
+ "@eslint/js": "^9.39.4",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/router-plugin": "^1.164.0",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.5.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
- "@typescript-eslint/eslint-plugin": "^8.56.1",
+ "@typescript-eslint/eslint-plugin": "^8.57.1",
"@vitejs/plugin-react": "^5.2.0",
- "eslint": "^9.39.3",
+ "eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
- "eslint-plugin-react-refresh": "^0.4.24",
+ "eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml
index 3ef12c088..f893abda9 100644
--- a/web/frontend/pnpm-lock.yaml
+++ b/web/frontend/pnpm-lock.yaml
@@ -19,13 +19,13 @@ importers:
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))
'@tanstack/react-query':
specifier: ^5.90.21
- version: 5.90.21(react@19.2.4)
+ version: 5.91.2(react@19.2.4)
'@tanstack/react-router':
specifier: ^1.167.0
- version: 1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ version: 1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tanstack/react-router-devtools':
specifier: ^1.163.3
- version: 1.163.3(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.0)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ 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)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -37,7 +37,7 @@ importers:
version: 1.11.20
i18next:
specifier: ^25.8.14
- version: 25.8.14(typescript@5.9.3)
+ version: 25.8.20(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.5.8(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
+ 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)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
@@ -85,14 +85,14 @@ importers:
version: 10.0.0
devDependencies:
'@eslint/js':
- specifier: ^9.39.3
- version: 9.39.3
+ specifier: ^9.39.4
+ version: 9.39.4
'@tailwindcss/typography':
specifier: ^0.5.19
version: 0.5.19(tailwindcss@4.2.2)
'@tanstack/router-plugin':
specifier: ^1.164.0
- version: 1.164.0(@tanstack/react-router@1.167.0(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.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))
'@trivago/prettier-plugin-sort-imports':
specifier: ^6.0.2
version: 6.0.2(prettier@3.8.1)
@@ -106,23 +106,23 @@ importers:
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
'@typescript-eslint/eslint-plugin':
- specifier: ^8.56.1
- version: 8.56.1(@typescript-eslint/parser@8.57.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
+ 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)
'@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))
eslint:
- specifier: ^9.39.3
- version: 9.39.3(jiti@2.6.1)
+ specifier: ^9.39.4
+ version: 9.39.4(jiti@2.6.1)
eslint-config-prettier:
specifier: ^10.1.8
- version: 10.1.8(eslint@9.39.3(jiti@2.6.1))
+ version: 10.1.8(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react-hooks:
specifier: ^7.0.1
- version: 7.0.1(eslint@9.39.3(jiti@2.6.1))
+ version: 7.0.1(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react-refresh:
- specifier: ^0.4.24
- version: 0.4.26(eslint@9.39.3(jiti@2.6.1))
+ specifier: ^0.4.26
+ version: 0.4.26(eslint@9.39.4(jiti@2.6.1))
globals:
specifier: ^16.5.0
version: 16.5.0
@@ -137,7 +137,7 @@ importers:
version: 5.9.3
typescript-eslint:
specifier: ^8.57.1
- version: 8.57.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
+ version: 8.57.1(eslint@9.39.4(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)
@@ -226,11 +226,6 @@ packages:
resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==}
engines: {node: '>=6.9.0'}
- '@babel/parser@7.29.0':
- resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
- engines: {node: '>=6.0.0'}
- hasBin: true
-
'@babel/parser@7.29.2':
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
engines: {node: '>=6.0.0'}
@@ -278,8 +273,8 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
- '@babel/runtime@7.28.6':
- resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
+ '@babel/runtime@7.29.2':
+ resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
'@babel/template@7.28.6':
@@ -304,158 +299,158 @@ packages:
peerDependencies:
'@noble/ciphers': ^1.0.0
- '@esbuild/aix-ppc64@0.27.3':
- resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
+ '@esbuild/aix-ppc64@0.27.4':
+ resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
- '@esbuild/android-arm64@0.27.3':
- resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
+ '@esbuild/android-arm64@0.27.4':
+ resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
- '@esbuild/android-arm@0.27.3':
- resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
+ '@esbuild/android-arm@0.27.4':
+ resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
- '@esbuild/android-x64@0.27.3':
- resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
+ '@esbuild/android-x64@0.27.4':
+ resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
- '@esbuild/darwin-arm64@0.27.3':
- resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
+ '@esbuild/darwin-arm64@0.27.4':
+ resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
- '@esbuild/darwin-x64@0.27.3':
- resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
+ '@esbuild/darwin-x64@0.27.4':
+ resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
- '@esbuild/freebsd-arm64@0.27.3':
- resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
+ '@esbuild/freebsd-arm64@0.27.4':
+ resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
- '@esbuild/freebsd-x64@0.27.3':
- resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
+ '@esbuild/freebsd-x64@0.27.4':
+ resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
- '@esbuild/linux-arm64@0.27.3':
- resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
+ '@esbuild/linux-arm64@0.27.4':
+ resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
- '@esbuild/linux-arm@0.27.3':
- resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
+ '@esbuild/linux-arm@0.27.4':
+ resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
- '@esbuild/linux-ia32@0.27.3':
- resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
+ '@esbuild/linux-ia32@0.27.4':
+ resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
- '@esbuild/linux-loong64@0.27.3':
- resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
+ '@esbuild/linux-loong64@0.27.4':
+ resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
- '@esbuild/linux-mips64el@0.27.3':
- resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
+ '@esbuild/linux-mips64el@0.27.4':
+ resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
- '@esbuild/linux-ppc64@0.27.3':
- resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
+ '@esbuild/linux-ppc64@0.27.4':
+ resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
- '@esbuild/linux-riscv64@0.27.3':
- resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
+ '@esbuild/linux-riscv64@0.27.4':
+ resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
- '@esbuild/linux-s390x@0.27.3':
- resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
+ '@esbuild/linux-s390x@0.27.4':
+ resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
- '@esbuild/linux-x64@0.27.3':
- resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
+ '@esbuild/linux-x64@0.27.4':
+ resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
- '@esbuild/netbsd-arm64@0.27.3':
- resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
+ '@esbuild/netbsd-arm64@0.27.4':
+ resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
- '@esbuild/netbsd-x64@0.27.3':
- resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
+ '@esbuild/netbsd-x64@0.27.4':
+ resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
- '@esbuild/openbsd-arm64@0.27.3':
- resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
+ '@esbuild/openbsd-arm64@0.27.4':
+ resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
- '@esbuild/openbsd-x64@0.27.3':
- resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
+ '@esbuild/openbsd-x64@0.27.4':
+ resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
- '@esbuild/openharmony-arm64@0.27.3':
- resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
+ '@esbuild/openharmony-arm64@0.27.4':
+ resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
- '@esbuild/sunos-x64@0.27.3':
- resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
+ '@esbuild/sunos-x64@0.27.4':
+ resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
- '@esbuild/win32-arm64@0.27.3':
- resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
+ '@esbuild/win32-arm64@0.27.4':
+ resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
- '@esbuild/win32-ia32@0.27.3':
- resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
+ '@esbuild/win32-ia32@0.27.4':
+ resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
- '@esbuild/win32-x64@0.27.3':
- resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
+ '@esbuild/win32-x64@0.27.4':
+ resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
@@ -486,8 +481,8 @@ packages:
resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@eslint/js@9.39.3':
- resolution: {integrity: sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==}
+ '@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@2.1.7':
@@ -498,20 +493,20 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@floating-ui/core@1.7.4':
- resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
+ '@floating-ui/core@1.7.5':
+ resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
- '@floating-ui/dom@1.7.5':
- resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==}
+ '@floating-ui/dom@1.7.6':
+ resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
- '@floating-ui/react-dom@2.1.7':
- resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==}
+ '@floating-ui/react-dom@2.1.8':
+ resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
- '@floating-ui/utils@0.2.10':
- resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
+ '@floating-ui/utils@0.2.11':
+ resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@fontsource-variable/inter@5.2.8':
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
@@ -1564,32 +1559,32 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
- '@tanstack/history@1.161.4':
- resolution: {integrity: sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww==}
+ '@tanstack/history@1.161.6':
+ resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==}
engines: {node: '>=20.19'}
- '@tanstack/query-core@5.90.20':
- resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==}
+ '@tanstack/query-core@5.91.2':
+ resolution: {integrity: sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==}
- '@tanstack/react-query@5.90.21':
- resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==}
+ '@tanstack/react-query@5.91.2':
+ resolution: {integrity: sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==}
peerDependencies:
react: ^18 || ^19
- '@tanstack/react-router-devtools@1.163.3':
- resolution: {integrity: sha512-42VMkV/2Z8ro7xzblPBRNZIEmCNXMzm2jD68G52p2qhjXm38wGpg46qneAESN9FtTQeVWk5aSXs47/jt7lkzmw==}
+ '@tanstack/react-router-devtools@1.166.9':
+ resolution: {integrity: sha512-O49eZmaeEKB5YnKH/qd61AbxV/lW8ICm4stfZ4GNQNpzQQ6rhPIB0p3PMZDIgX+6DoMivdNvLRmXAOOpzpIpDg==}
engines: {node: '>=20.19'}
peerDependencies:
- '@tanstack/react-router': ^1.163.3
- '@tanstack/router-core': ^1.163.3
+ '@tanstack/react-router': ^1.167.2
+ '@tanstack/router-core': ^1.167.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.0':
- resolution: {integrity: sha512-U7CamtXjuC8ixg1c32Rj/4A2OFBnjtMLdbgbyOGHrFHE7ULWS/yhnZLVXff0QSyn6qF92Oecek9mDMHCaTnB2Q==}
+ '@tanstack/react-router@1.167.5':
+ resolution: {integrity: sha512-s1nP6l/7BYZfSwhoNbB7/rUmZ07q/AvkmhBoiDQl3tgy5dpb9Q1qjtIapYdvCOrao1aA/QCaWqxcbGc2Ct1bvQ==}
engines: {node: '>=20.19'}
peerDependencies:
react: '>=18.0.0 || >=19.0.0'
@@ -1601,34 +1596,32 @@ packages:
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.163.3':
- resolution: {integrity: sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA==}
+ '@tanstack/router-core@1.167.5':
+ resolution: {integrity: sha512-8fRgJ0zNJf77R4grCaJQ5Imatjyc4YT5v8rlsPkYYYeUlcFNLbuFRhLlAMdND9gRUMznpnbRDXngpTPgx2K7HQ==}
engines: {node: '>=20.19'}
+ hasBin: true
- '@tanstack/router-core@1.167.0':
- resolution: {integrity: sha512-pnaaUP+vMQEyL2XjZGe2PXmtzulxvXfGyvEMUs+AEBaNEk77xWA88bl3ujiBRbUxzpK0rxfJf+eSKPdZmBMFdQ==}
- engines: {node: '>=20.19'}
-
- '@tanstack/router-devtools-core@1.163.3':
- resolution: {integrity: sha512-FPi64IP0PT1IkoeyGmsD6JoOVOYAb85VCH0mUbSdD90yV0+1UB6oT+D7K27GXkp7SXMJN3mBEjU5rKnNnmSCIw==}
+ '@tanstack/router-devtools-core@1.166.9':
+ resolution: {integrity: sha512-PNlA7GmOUX9wY7LUG709Pk3Lg33dfHBztQwzjzrOiOsuf4ggp2R6bwarF8nYGNjG79z/MaB5PN+5yvkCVk8jGw==}
engines: {node: '>=20.19'}
peerDependencies:
- '@tanstack/router-core': ^1.163.3
+ '@tanstack/router-core': ^1.167.2
csstype: ^3.0.10
peerDependenciesMeta:
csstype:
optional: true
- '@tanstack/router-generator@1.164.0':
- resolution: {integrity: sha512-Uiyj+RtW0kdeqEd8NEd3Np1Z2nhJ2xgLS8U+5mTvFrm/s3xkM2LYjJHoLzc6am7sKPDsmeF9a4/NYq3R7ZJP0Q==}
+ '@tanstack/router-generator@1.166.13':
+ resolution: {integrity: sha512-ALxSs6OzimiSgpOuIm+AXmc7eUx/oGPwSPpdQbpZ/kX7WHRh6qM7lv8DAN0K3jWcBpzF8eeOIdryWryX8gH+Yg==}
engines: {node: '>=20.19'}
- '@tanstack/router-plugin@1.164.0':
- resolution: {integrity: sha512-cZPsEMhqzyzmuPuDbsTAzBZaT+cj0pGjwdhjxJfPCM06Ax8v4tFR7n/Ug0UCwnNAUEmKZWN3lA9uT+TxXnk9PQ==}
+ '@tanstack/router-plugin@1.166.14':
+ resolution: {integrity: sha512-hypyj0qlsAbJf60/glmVYqSVwnRB4hKRrMCUsSXjrPdO2g6gs3z6xHmcWsHQ831C4G9+bSFEK9Uy5EjO3A4THQ==}
engines: {node: '>=20.19'}
+ hasBin: true
peerDependencies:
'@rsbuild/core': '>=1.0.2'
- '@tanstack/react-router': ^1.163.3
+ '@tanstack/react-router': ^1.167.5
vite: '>=5.0.0 || >=6.0.0 || >=7.0.0'
vite-plugin-solid: ^2.11.10
webpack: '>=5.92.0'
@@ -1644,19 +1637,17 @@ packages:
webpack:
optional: true
- '@tanstack/router-utils@1.161.4':
- resolution: {integrity: sha512-r8TpjyIZoqrXXaf2DDyjd44gjGBoyE+/oEaaH68yLI9ySPO1gUWmQENZ1MZnmBnpUGN24NOZxdjDLc8npK0SAw==}
+ '@tanstack/router-utils@1.161.6':
+ resolution: {integrity: sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw==}
engines: {node: '>=20.19'}
- '@tanstack/store@0.9.1':
- resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==}
-
'@tanstack/store@0.9.2':
resolution: {integrity: sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==}
- '@tanstack/virtual-file-routes@1.161.4':
- resolution: {integrity: sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w==}
+ '@tanstack/virtual-file-routes@1.161.7':
+ resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==}
engines: {node: '>=20.19'}
+ hasBin: true
'@trivago/prettier-plugin-sort-imports@6.0.2':
resolution: {integrity: sha512-3DgfkukFyC/sE/VuYjaUUWoFfuVjPK55vOFDsxD56XXynFMCZDYFogH2l/hDfOsQAm1myoU/1xByJ3tWqtulXA==}
@@ -1692,8 +1683,8 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
- '@types/debug@4.1.12':
- resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
+ '@types/debug@4.1.13':
+ resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@@ -1736,14 +1727,6 @@ packages:
'@types/validate-npm-package-name@4.0.2':
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
- '@typescript-eslint/eslint-plugin@8.56.1':
- resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- peerDependencies:
- '@typescript-eslint/parser': ^8.56.1
- eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
- typescript: '>=4.8.4 <6.0.0'
-
'@typescript-eslint/eslint-plugin@8.57.1':
resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1759,45 +1742,22 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/project-service@8.56.1':
- resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- peerDependencies:
- typescript: '>=4.8.4 <6.0.0'
-
'@typescript-eslint/project-service@8.57.1':
resolution: {integrity: sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/scope-manager@8.56.1':
- resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
'@typescript-eslint/scope-manager@8.57.1':
resolution: {integrity: sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/tsconfig-utils@8.56.1':
- resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- peerDependencies:
- typescript: '>=4.8.4 <6.0.0'
-
'@typescript-eslint/tsconfig-utils@8.57.1':
resolution: {integrity: sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/type-utils@8.56.1':
- resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==}
- 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/type-utils@8.57.1':
resolution: {integrity: sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1805,33 +1765,16 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/types@8.56.1':
- resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
'@typescript-eslint/types@8.57.1':
resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/typescript-estree@8.56.1':
- resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- peerDependencies:
- typescript: '>=4.8.4 <6.0.0'
-
'@typescript-eslint/typescript-estree@8.57.1':
resolution: {integrity: sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/utils@8.56.1':
- resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==}
- 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/utils@8.57.1':
resolution: {integrity: sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1839,10 +1782,6 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/visitor-keys@8.56.1':
- resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
'@typescript-eslint/visitor-keys@8.57.1':
resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -2212,8 +2151,8 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
- enhanced-resolve@5.20.0:
- resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
+ enhanced-resolve@5.20.1:
+ resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'}
env-paths@2.2.1:
@@ -2235,8 +2174,8 @@ packages:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
- esbuild@0.27.3:
- resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
+ esbuild@0.27.4:
+ resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'}
hasBin: true
@@ -2288,8 +2227,8 @@ packages:
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
- eslint@9.39.3:
- resolution: {integrity: sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==}
+ eslint@9.39.4:
+ resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
hasBin: true
peerDependencies:
@@ -2415,8 +2354,8 @@ packages:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
- flatted@3.4.1:
- resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==}
+ flatted@3.4.2:
+ resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
formdata-polyfill@4.0.10:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
@@ -2572,8 +2511,8 @@ packages:
i18next-browser-languagedetector@8.2.1:
resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==}
- i18next@25.8.14:
- resolution: {integrity: sha512-paMUYkfWJMsWPeE/Hejcw+XLhHrQPehem+4wMo+uELnvIwvCG019L9sAIljwjCmEMtFQQO3YeitJY8Kctei3iA==}
+ i18next@25.8.20:
+ resolution: {integrity: sha512-xjo9+lbX/P1tQt3xpO2rfJiBppNfUnNIPKgCvNsTKsvTOCro1Qr/geXVg1N47j5ScOSaXAPq8ET93raK3Rr06A==}
peerDependencies:
typescript: ^5
peerDependenciesMeta:
@@ -3253,10 +3192,6 @@ packages:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
engines: {node: '>=4'}
- postcss@8.5.6:
- resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
- engines: {node: ^10 || ^12 || >=14}
-
postcss@8.5.8:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
@@ -3528,22 +3463,12 @@ packages:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
- seroval-plugins@1.5.0:
- resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==}
- engines: {node: '>=10'}
- peerDependencies:
- seroval: ^1.0
-
seroval-plugins@1.5.1:
resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==}
engines: {node: '>=10'}
peerDependencies:
seroval: ^1.0
- seroval@1.5.0:
- resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==}
- engines: {node: '>=10'}
-
seroval@1.5.1:
resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==}
engines: {node: '>=10'}
@@ -3727,12 +3652,6 @@ packages:
trough@2.2.0:
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
- ts-api-utils@2.4.0:
- resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
- engines: {node: '>=18.12'}
- peerDependencies:
- typescript: '>=4.8.4'
-
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
@@ -4062,7 +3981,7 @@ snapshots:
'@babel/generator@7.29.1':
dependencies:
- '@babel/parser': 7.29.0
+ '@babel/parser': 7.29.2
'@babel/types': 7.29.0
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
@@ -4151,10 +4070,6 @@ snapshots:
'@babel/template': 7.28.6
'@babel/types': 7.29.0
- '@babel/parser@7.29.0':
- dependencies:
- '@babel/types': 7.29.0
-
'@babel/parser@7.29.2':
dependencies:
'@babel/types': 7.29.0
@@ -4209,7 +4124,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@babel/runtime@7.28.6': {}
+ '@babel/runtime@7.29.2': {}
'@babel/template@7.28.6':
dependencies:
@@ -4250,87 +4165,87 @@ snapshots:
dependencies:
'@noble/ciphers': 1.3.0
- '@esbuild/aix-ppc64@0.27.3':
+ '@esbuild/aix-ppc64@0.27.4':
optional: true
- '@esbuild/android-arm64@0.27.3':
+ '@esbuild/android-arm64@0.27.4':
optional: true
- '@esbuild/android-arm@0.27.3':
+ '@esbuild/android-arm@0.27.4':
optional: true
- '@esbuild/android-x64@0.27.3':
+ '@esbuild/android-x64@0.27.4':
optional: true
- '@esbuild/darwin-arm64@0.27.3':
+ '@esbuild/darwin-arm64@0.27.4':
optional: true
- '@esbuild/darwin-x64@0.27.3':
+ '@esbuild/darwin-x64@0.27.4':
optional: true
- '@esbuild/freebsd-arm64@0.27.3':
+ '@esbuild/freebsd-arm64@0.27.4':
optional: true
- '@esbuild/freebsd-x64@0.27.3':
+ '@esbuild/freebsd-x64@0.27.4':
optional: true
- '@esbuild/linux-arm64@0.27.3':
+ '@esbuild/linux-arm64@0.27.4':
optional: true
- '@esbuild/linux-arm@0.27.3':
+ '@esbuild/linux-arm@0.27.4':
optional: true
- '@esbuild/linux-ia32@0.27.3':
+ '@esbuild/linux-ia32@0.27.4':
optional: true
- '@esbuild/linux-loong64@0.27.3':
+ '@esbuild/linux-loong64@0.27.4':
optional: true
- '@esbuild/linux-mips64el@0.27.3':
+ '@esbuild/linux-mips64el@0.27.4':
optional: true
- '@esbuild/linux-ppc64@0.27.3':
+ '@esbuild/linux-ppc64@0.27.4':
optional: true
- '@esbuild/linux-riscv64@0.27.3':
+ '@esbuild/linux-riscv64@0.27.4':
optional: true
- '@esbuild/linux-s390x@0.27.3':
+ '@esbuild/linux-s390x@0.27.4':
optional: true
- '@esbuild/linux-x64@0.27.3':
+ '@esbuild/linux-x64@0.27.4':
optional: true
- '@esbuild/netbsd-arm64@0.27.3':
+ '@esbuild/netbsd-arm64@0.27.4':
optional: true
- '@esbuild/netbsd-x64@0.27.3':
+ '@esbuild/netbsd-x64@0.27.4':
optional: true
- '@esbuild/openbsd-arm64@0.27.3':
+ '@esbuild/openbsd-arm64@0.27.4':
optional: true
- '@esbuild/openbsd-x64@0.27.3':
+ '@esbuild/openbsd-x64@0.27.4':
optional: true
- '@esbuild/openharmony-arm64@0.27.3':
+ '@esbuild/openharmony-arm64@0.27.4':
optional: true
- '@esbuild/sunos-x64@0.27.3':
+ '@esbuild/sunos-x64@0.27.4':
optional: true
- '@esbuild/win32-arm64@0.27.3':
+ '@esbuild/win32-arm64@0.27.4':
optional: true
- '@esbuild/win32-ia32@0.27.3':
+ '@esbuild/win32-ia32@0.27.4':
optional: true
- '@esbuild/win32-x64@0.27.3':
+ '@esbuild/win32-x64@0.27.4':
optional: true
- '@eslint-community/eslint-utils@4.9.1(eslint@9.39.3(jiti@2.6.1))':
+ '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
dependencies:
- eslint: 9.39.3(jiti@2.6.1)
+ eslint: 9.39.4(jiti@2.6.1)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
@@ -4365,7 +4280,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@eslint/js@9.39.3': {}
+ '@eslint/js@9.39.4': {}
'@eslint/object-schema@2.1.7': {}
@@ -4374,22 +4289,22 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
- '@floating-ui/core@1.7.4':
+ '@floating-ui/core@1.7.5':
dependencies:
- '@floating-ui/utils': 0.2.10
+ '@floating-ui/utils': 0.2.11
- '@floating-ui/dom@1.7.5':
+ '@floating-ui/dom@1.7.6':
dependencies:
- '@floating-ui/core': 1.7.4
- '@floating-ui/utils': 0.2.10
+ '@floating-ui/core': 1.7.5
+ '@floating-ui/utils': 0.2.11
- '@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
- '@floating-ui/dom': 1.7.5
+ '@floating-ui/dom': 1.7.6
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
- '@floating-ui/utils@0.2.10': {}
+ '@floating-ui/utils@0.2.11': {}
'@fontsource-variable/inter@5.2.8': {}
@@ -4907,7 +4822,7 @@ snapshots:
'@radix-ui/react-popper@1.2.8(@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)':
dependencies:
- '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-arrow': 1.1.7(@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)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
@@ -5353,7 +5268,7 @@ snapshots:
'@tailwindcss/node@4.2.2':
dependencies:
'@jridgewell/remapping': 2.3.5
- enhanced-resolve: 5.20.0
+ enhanced-resolve: 5.20.1
jiti: 2.6.1
lightningcss: 1.32.0
magic-string: 0.30.21
@@ -5423,31 +5338,31 @@ snapshots:
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)
- '@tanstack/history@1.161.4': {}
+ '@tanstack/history@1.161.6': {}
- '@tanstack/query-core@5.90.20': {}
+ '@tanstack/query-core@5.91.2': {}
- '@tanstack/react-query@5.90.21(react@19.2.4)':
+ '@tanstack/react-query@5.91.2(react@19.2.4)':
dependencies:
- '@tanstack/query-core': 5.90.20
+ '@tanstack/query-core': 5.91.2
react: 19.2.4
- '@tanstack/react-router-devtools@1.163.3(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.0)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(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)':
dependencies:
- '@tanstack/react-router': 1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@tanstack/router-devtools-core': 1.163.3(@tanstack/router-core@1.167.0)(csstype@3.2.3)
+ '@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)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
- '@tanstack/router-core': 1.167.0
+ '@tanstack/router-core': 1.167.5
transitivePeerDependencies:
- csstype
- '@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
- '@tanstack/history': 1.161.4
+ '@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.0
+ '@tanstack/router-core': 1.167.5
isbot: 5.1.36
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
@@ -5461,19 +5376,9 @@ snapshots:
react-dom: 19.2.4(react@19.2.4)
use-sync-external-store: 1.6.0(react@19.2.4)
- '@tanstack/router-core@1.163.3':
+ '@tanstack/router-core@1.167.5':
dependencies:
- '@tanstack/history': 1.161.4
- '@tanstack/store': 0.9.1
- cookie-es: 2.0.0
- seroval: 1.5.0
- seroval-plugins: 1.5.0(seroval@1.5.0)
- tiny-invariant: 1.3.3
- tiny-warning: 1.0.3
-
- '@tanstack/router-core@1.167.0':
- dependencies:
- '@tanstack/history': 1.161.4
+ '@tanstack/history': 1.161.6
'@tanstack/store': 0.9.2
cookie-es: 2.0.0
seroval: 1.5.1
@@ -5481,20 +5386,20 @@ snapshots:
tiny-invariant: 1.3.3
tiny-warning: 1.0.3
- '@tanstack/router-devtools-core@1.163.3(@tanstack/router-core@1.167.0)(csstype@3.2.3)':
+ '@tanstack/router-devtools-core@1.166.9(@tanstack/router-core@1.167.5)(csstype@3.2.3)':
dependencies:
- '@tanstack/router-core': 1.167.0
+ '@tanstack/router-core': 1.167.5
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.164.0':
+ '@tanstack/router-generator@1.166.13':
dependencies:
- '@tanstack/router-core': 1.163.3
- '@tanstack/router-utils': 1.161.4
- '@tanstack/virtual-file-routes': 1.161.4
+ '@tanstack/router-core': 1.167.5
+ '@tanstack/router-utils': 1.161.6
+ '@tanstack/virtual-file-routes': 1.161.7
prettier: 3.8.1
recast: 0.23.11
source-map: 0.7.6
@@ -5503,7 +5408,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@tanstack/router-plugin@1.164.0(@tanstack/react-router@1.167.0(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.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))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
@@ -5511,20 +5416,20 @@ snapshots:
'@babel/template': 7.28.6
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
- '@tanstack/router-core': 1.163.3
- '@tanstack/router-generator': 1.164.0
- '@tanstack/router-utils': 1.161.4
- '@tanstack/virtual-file-routes': 1.161.4
+ '@tanstack/router-core': 1.167.5
+ '@tanstack/router-generator': 1.166.13
+ '@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.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@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)
transitivePeerDependencies:
- supports-color
- '@tanstack/router-utils@1.161.4':
+ '@tanstack/router-utils@1.161.6':
dependencies:
'@babel/core': 7.29.0
'@babel/generator': 7.29.1
@@ -5538,16 +5443,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@tanstack/store@0.9.1': {}
-
'@tanstack/store@0.9.2': {}
- '@tanstack/virtual-file-routes@1.161.4': {}
+ '@tanstack/virtual-file-routes@1.161.7': {}
'@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1)':
dependencies:
'@babel/generator': 7.29.1
- '@babel/parser': 7.29.0
+ '@babel/parser': 7.29.2
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
javascript-natural-sort: 0.7.1
@@ -5585,7 +5488,7 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
- '@types/debug@4.1.12':
+ '@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
@@ -5627,31 +5530,15 @@ snapshots:
'@types/validate-npm-package-name@4.0.2': {}
- '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.57.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
+ '@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)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.57.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/scope-manager': 8.56.1
- '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/visitor-keys': 8.56.1
- eslint: 9.39.3(jiti@2.6.1)
- ignore: 7.0.5
- natural-compare: 1.4.0
- ts-api-utils: 2.4.0(typescript@5.9.3)
- typescript: 5.9.3
- transitivePeerDependencies:
- - supports-color
-
- '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
- dependencies:
- '@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.57.1(eslint@9.39.3(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/scope-manager': 8.57.1
- '@typescript-eslint/type-utils': 8.57.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/utils': 8.57.1(eslint@9.39.3(jiti@2.6.1))(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/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.3(jiti@2.6.1)
+ eslint: 9.39.4(jiti@2.6.1)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -5659,23 +5546,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.57.1(eslint@9.39.3(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)':
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
debug: 4.4.3
- eslint: 9.39.3(jiti@2.6.1)
- typescript: 5.9.3
- transitivePeerDependencies:
- - supports-color
-
- '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)':
- dependencies:
- '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3)
- '@typescript-eslint/types': 8.57.1
- debug: 4.4.3
+ eslint: 9.39.4(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -5689,67 +5567,29 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/scope-manager@8.56.1':
- dependencies:
- '@typescript-eslint/types': 8.56.1
- '@typescript-eslint/visitor-keys': 8.56.1
-
'@typescript-eslint/scope-manager@8.57.1':
dependencies:
'@typescript-eslint/types': 8.57.1
'@typescript-eslint/visitor-keys': 8.57.1
- '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)':
- dependencies:
- typescript: 5.9.3
-
'@typescript-eslint/tsconfig-utils@8.57.1(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
- '@typescript-eslint/type-utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
- dependencies:
- '@typescript-eslint/types': 8.56.1
- '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
- '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
- debug: 4.4.3
- eslint: 9.39.3(jiti@2.6.1)
- ts-api-utils: 2.4.0(typescript@5.9.3)
- typescript: 5.9.3
- transitivePeerDependencies:
- - supports-color
-
- '@typescript-eslint/type-utils@8.57.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/type-utils@8.57.1(eslint@9.39.4(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.3(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)
debug: 4.4.3
- eslint: 9.39.3(jiti@2.6.1)
+ eslint: 9.39.4(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.56.1': {}
-
'@typescript-eslint/types@8.57.1': {}
- '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)':
- dependencies:
- '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3)
- '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3)
- '@typescript-eslint/types': 8.56.1
- '@typescript-eslint/visitor-keys': 8.56.1
- debug: 4.4.3
- minimatch: 10.2.4
- semver: 7.7.4
- tinyglobby: 0.2.15
- ts-api-utils: 2.4.0(typescript@5.9.3)
- typescript: 5.9.3
- transitivePeerDependencies:
- - supports-color
-
'@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.57.1(typescript@5.9.3)
@@ -5765,33 +5605,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.56.1(eslint@9.39.3(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)':
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1))
- '@typescript-eslint/scope-manager': 8.56.1
- '@typescript-eslint/types': 8.56.1
- '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
- eslint: 9.39.3(jiti@2.6.1)
- typescript: 5.9.3
- transitivePeerDependencies:
- - supports-color
-
- '@typescript-eslint/utils@8.57.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
- dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1))
+ '@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.3(jiti@2.6.1)
+ eslint: 9.39.4(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/visitor-keys@8.56.1':
- dependencies:
- '@typescript-eslint/types': 8.56.1
- eslint-visitor-keys: 5.0.1
-
'@typescript-eslint/visitor-keys@8.57.1':
dependencies:
'@typescript-eslint/types': 8.57.1
@@ -6116,7 +5940,7 @@ snapshots:
encodeurl@2.0.0: {}
- enhanced-resolve@5.20.0:
+ enhanced-resolve@5.20.1:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.0
@@ -6135,34 +5959,34 @@ snapshots:
dependencies:
es-errors: 1.3.0
- esbuild@0.27.3:
+ esbuild@0.27.4:
optionalDependencies:
- '@esbuild/aix-ppc64': 0.27.3
- '@esbuild/android-arm': 0.27.3
- '@esbuild/android-arm64': 0.27.3
- '@esbuild/android-x64': 0.27.3
- '@esbuild/darwin-arm64': 0.27.3
- '@esbuild/darwin-x64': 0.27.3
- '@esbuild/freebsd-arm64': 0.27.3
- '@esbuild/freebsd-x64': 0.27.3
- '@esbuild/linux-arm': 0.27.3
- '@esbuild/linux-arm64': 0.27.3
- '@esbuild/linux-ia32': 0.27.3
- '@esbuild/linux-loong64': 0.27.3
- '@esbuild/linux-mips64el': 0.27.3
- '@esbuild/linux-ppc64': 0.27.3
- '@esbuild/linux-riscv64': 0.27.3
- '@esbuild/linux-s390x': 0.27.3
- '@esbuild/linux-x64': 0.27.3
- '@esbuild/netbsd-arm64': 0.27.3
- '@esbuild/netbsd-x64': 0.27.3
- '@esbuild/openbsd-arm64': 0.27.3
- '@esbuild/openbsd-x64': 0.27.3
- '@esbuild/openharmony-arm64': 0.27.3
- '@esbuild/sunos-x64': 0.27.3
- '@esbuild/win32-arm64': 0.27.3
- '@esbuild/win32-ia32': 0.27.3
- '@esbuild/win32-x64': 0.27.3
+ '@esbuild/aix-ppc64': 0.27.4
+ '@esbuild/android-arm': 0.27.4
+ '@esbuild/android-arm64': 0.27.4
+ '@esbuild/android-x64': 0.27.4
+ '@esbuild/darwin-arm64': 0.27.4
+ '@esbuild/darwin-x64': 0.27.4
+ '@esbuild/freebsd-arm64': 0.27.4
+ '@esbuild/freebsd-x64': 0.27.4
+ '@esbuild/linux-arm': 0.27.4
+ '@esbuild/linux-arm64': 0.27.4
+ '@esbuild/linux-ia32': 0.27.4
+ '@esbuild/linux-loong64': 0.27.4
+ '@esbuild/linux-mips64el': 0.27.4
+ '@esbuild/linux-ppc64': 0.27.4
+ '@esbuild/linux-riscv64': 0.27.4
+ '@esbuild/linux-s390x': 0.27.4
+ '@esbuild/linux-x64': 0.27.4
+ '@esbuild/netbsd-arm64': 0.27.4
+ '@esbuild/netbsd-x64': 0.27.4
+ '@esbuild/openbsd-arm64': 0.27.4
+ '@esbuild/openbsd-x64': 0.27.4
+ '@esbuild/openharmony-arm64': 0.27.4
+ '@esbuild/sunos-x64': 0.27.4
+ '@esbuild/win32-arm64': 0.27.4
+ '@esbuild/win32-ia32': 0.27.4
+ '@esbuild/win32-x64': 0.27.4
escalade@3.2.0: {}
@@ -6172,24 +5996,24 @@ snapshots:
escape-string-regexp@5.0.0: {}
- eslint-config-prettier@10.1.8(eslint@9.39.3(jiti@2.6.1)):
+ eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)):
dependencies:
- eslint: 9.39.3(jiti@2.6.1)
+ eslint: 9.39.4(jiti@2.6.1)
- eslint-plugin-react-hooks@7.0.1(eslint@9.39.3(jiti@2.6.1)):
+ eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@babel/core': 7.29.0
- '@babel/parser': 7.29.0
- eslint: 9.39.3(jiti@2.6.1)
+ '@babel/parser': 7.29.2
+ eslint: 9.39.4(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.3(jiti@2.6.1)):
+ eslint-plugin-react-refresh@0.4.26(eslint@9.39.4(jiti@2.6.1)):
dependencies:
- eslint: 9.39.3(jiti@2.6.1)
+ eslint: 9.39.4(jiti@2.6.1)
eslint-scope@8.4.0:
dependencies:
@@ -6202,15 +6026,15 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
- eslint@9.39.3(jiti@2.6.1):
+ eslint@9.39.4(jiti@2.6.1):
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(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.3
+ '@eslint/js': 9.39.4
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.7
'@humanwhocodes/module-importer': 1.0.1
@@ -6399,10 +6223,10 @@ snapshots:
flat-cache@4.0.1:
dependencies:
- flatted: 3.4.1
+ flatted: 3.4.2
keyv: 4.5.4
- flatted@3.4.1: {}
+ flatted@3.4.2: {}
formdata-polyfill@4.0.10:
dependencies:
@@ -6555,11 +6379,11 @@ snapshots:
i18next-browser-languagedetector@8.2.1:
dependencies:
- '@babel/runtime': 7.28.6
+ '@babel/runtime': 7.29.2
- i18next@25.8.14(typescript@5.9.3):
+ i18next@25.8.20(typescript@5.9.3):
dependencies:
- '@babel/runtime': 7.28.6
+ '@babel/runtime': 7.29.2
optionalDependencies:
typescript: 5.9.3
@@ -7115,7 +6939,7 @@ snapshots:
micromark@4.0.2:
dependencies:
- '@types/debug': 4.1.12
+ '@types/debug': 4.1.13
debug: 4.4.3
decode-named-character-reference: 1.3.0
devlop: 1.1.0
@@ -7345,12 +7169,6 @@ snapshots:
cssesc: 3.0.0
util-deprecate: 1.0.2
- postcss@8.5.6:
- dependencies:
- nanoid: 3.3.11
- picocolors: 1.1.1
- source-map-js: 1.2.1
-
postcss@8.5.8:
dependencies:
nanoid: 3.3.11
@@ -7470,11 +7288,11 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
- react-i18next@16.5.8(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
+ 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):
dependencies:
- '@babel/runtime': 7.28.6
+ '@babel/runtime': 7.29.2
html-parse-stringify: 3.0.1
- i18next: 25.8.14(typescript@5.9.3)
+ i18next: 25.8.20(typescript@5.9.3)
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
@@ -7530,7 +7348,7 @@ snapshots:
react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.4):
dependencies:
- '@babel/runtime': 7.28.6
+ '@babel/runtime': 7.29.2
react: 19.2.4
use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.4)
use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.4)
@@ -7673,16 +7491,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
- seroval-plugins@1.5.0(seroval@1.5.0):
- dependencies:
- seroval: 1.5.0
-
seroval-plugins@1.5.1(seroval@1.5.1):
dependencies:
seroval: 1.5.1
- seroval@1.5.0: {}
-
seroval@1.5.1: {}
serve-static@2.2.1:
@@ -7891,10 +7703,6 @@ snapshots:
trough@2.2.0: {}
- ts-api-utils@2.4.0(typescript@5.9.3):
- dependencies:
- typescript: 5.9.3
-
ts-api-utils@2.5.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -7914,7 +7722,7 @@ snapshots:
tsx@4.21.0:
dependencies:
- esbuild: 0.27.3
+ esbuild: 0.27.4
get-tsconfig: 4.13.6
optionalDependencies:
fsevents: 2.3.3
@@ -7935,13 +7743,13 @@ snapshots:
media-typer: 1.1.0
mime-types: 3.0.2
- typescript-eslint@8.57.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3):
+ typescript-eslint@8.57.1(eslint@9.39.4(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.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/parser': 8.57.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
+ '@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.3(jiti@2.6.1))(typescript@5.9.3)
- eslint: 9.39.3(jiti@2.6.1)
+ '@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: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -8064,10 +7872,10 @@ snapshots:
vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0):
dependencies:
- esbuild: 0.27.3
+ esbuild: 0.27.4
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
- postcss: 8.5.6
+ postcss: 8.5.8
rollup: 4.59.0
tinyglobby: 0.2.15
optionalDependencies:
From 68d182a26ee09819a0ed690a67a1823c3862381d Mon Sep 17 00:00:00 2001
From: wenjie
Date: Fri, 20 Mar 2026 15:19:33 +0800
Subject: [PATCH 26/44] chore(deps): bump Go toolchain to 1.25.8 for stdlib
security fixes (#1821)
---
go.mod | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/go.mod b/go.mod
index e858c3642..5256b097d 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/sipeed/picoclaw
-go 1.25.7
+go 1.25.8
require (
fyne.io/systray v1.12.0
From fe87376d6a6f95c2a3028d963f87cfc45748d30d Mon Sep 17 00:00:00 2001
From: wenjie
Date: Fri, 20 Mar 2026 16:13:10 +0800
Subject: [PATCH 27/44] chore(deps): upgrade modelcontextprotocol go-sdk to
v1.4.1 for security fixes (#1823)
---
go.mod | 4 ++--
go.sum | 12 ++++++------
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/go.mod b/go.mod
index 5256b097d..39385edca 100644
--- a/go.mod
+++ b/go.mod
@@ -17,7 +17,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/modelcontextprotocol/go-sdk v1.3.1
+ 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
github.com/openai/openai-go/v3 v3.22.0
@@ -56,7 +56,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/segmentio/asm v1.1.3 // indirect
- github.com/segmentio/encoding v0.5.3 // indirect
+ github.com/segmentio/encoding v0.5.4 // indirect
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
diff --git a/go.sum b/go.sum
index 2e4816018..3e6001480 100644
--- a/go.sum
+++ b/go.sum
@@ -70,8 +70,8 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
-github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
+github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@@ -140,8 +140,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/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI=
-github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw=
+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=
github.com/mymmrac/telego v1.7.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@@ -179,8 +179,8 @@ github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
-github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
-github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
+github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
+github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
From 998b456b6529cd30ed584ec18614b029f454a2e4 Mon Sep 17 00:00:00 2001
From: taorye
Date: Tue, 10 Mar 2026 16:51:26 +0800
Subject: [PATCH 28/44] Remove UI components and gateway management for
picoclaw-launcher-tui
- Deleted channel management UI from channel.go, including all associated forms and menu items.
- Removed platform-specific gateway process management from gateway_posix.go and gateway_windows.go.
- Eliminated menu structure and item management from menu.go.
- Removed model management and configuration handling from model.go.
- Deleted style definitions and application logic from style.go.
- Cleared main entry point in main.go.
---
.../internal/config/store.go | 49 --
cmd/picoclaw-launcher-tui/internal/ui/app.go | 522 ------------------
.../internal/ui/channel.go | 433 ---------------
.../internal/ui/gateway_posix.go | 16 -
.../internal/ui/gateway_windows.go | 16 -
cmd/picoclaw-launcher-tui/internal/ui/menu.go | 72 ---
.../internal/ui/model.go | 399 -------------
.../internal/ui/style.go | 55 --
cmd/picoclaw-launcher-tui/main.go | 15 -
9 files changed, 1577 deletions(-)
delete mode 100644 cmd/picoclaw-launcher-tui/internal/config/store.go
delete mode 100644 cmd/picoclaw-launcher-tui/internal/ui/app.go
delete mode 100644 cmd/picoclaw-launcher-tui/internal/ui/channel.go
delete mode 100644 cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go
delete mode 100644 cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go
delete mode 100644 cmd/picoclaw-launcher-tui/internal/ui/menu.go
delete mode 100644 cmd/picoclaw-launcher-tui/internal/ui/model.go
delete mode 100644 cmd/picoclaw-launcher-tui/internal/ui/style.go
delete mode 100644 cmd/picoclaw-launcher-tui/main.go
diff --git a/cmd/picoclaw-launcher-tui/internal/config/store.go b/cmd/picoclaw-launcher-tui/internal/config/store.go
deleted file mode 100644
index 0236de19f..000000000
--- a/cmd/picoclaw-launcher-tui/internal/config/store.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package configstore
-
-import (
- "errors"
- "os"
- "path/filepath"
-
- picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
-)
-
-const (
- configDirName = ".picoclaw"
- configFileName = "config.json"
-)
-
-func ConfigPath() (string, error) {
- dir, err := ConfigDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(dir, configFileName), nil
-}
-
-func ConfigDir() (string, error) {
- home, err := os.UserHomeDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(home, configDirName), nil
-}
-
-func Load() (*picoclawconfig.Config, error) {
- path, err := ConfigPath()
- if err != nil {
- return nil, err
- }
- return picoclawconfig.LoadConfig(path)
-}
-
-func Save(cfg *picoclawconfig.Config) error {
- if cfg == nil {
- return errors.New("config is nil")
- }
- path, err := ConfigPath()
- if err != nil {
- return err
- }
- return picoclawconfig.SaveConfig(path, cfg)
-}
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/app.go b/cmd/picoclaw-launcher-tui/internal/ui/app.go
deleted file mode 100644
index a2ccddf70..000000000
--- a/cmd/picoclaw-launcher-tui/internal/ui/app.go
+++ /dev/null
@@ -1,522 +0,0 @@
-package ui
-
-import (
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
-
- "github.com/gdamore/tcell/v2"
- "github.com/rivo/tview"
-
- configstore "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config"
- picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
-)
-
-type appState struct {
- app *tview.Application
- pages *tview.Pages
- stack []string
- config *picoclawconfig.Config
- configPath string
- gatewayCmd *exec.Cmd
- menus map[string]*Menu
- original []byte
- hasOriginal bool
- backupPath string
- dirty bool
- logPath string
-}
-
-func Run() error {
- applyStyles()
- cfg, err := configstore.Load()
- if err != nil {
- return err
- }
- path, err := configstore.ConfigPath()
- if err != nil {
- return err
- }
-
- if cfg == nil {
- cfg = picoclawconfig.DefaultConfig()
- }
-
- originalData, hasOriginal := loadOriginalConfig(path)
- backupPath := path + ".bak"
- if hasOriginal {
- _ = writeBackupConfig(backupPath, originalData)
- }
-
- logPath := filepath.Join(filepath.Dir(path), "gateway.log")
- state := &appState{
- app: tview.NewApplication(),
- pages: tview.NewPages(),
- config: cfg,
- configPath: path,
- menus: map[string]*Menu{},
- original: originalData,
- hasOriginal: hasOriginal,
- backupPath: backupPath,
- logPath: logPath,
- }
-
- state.push("main", state.mainMenu())
-
- root := tview.NewFlex().SetDirection(tview.FlexRow)
- root.AddItem(bannerView(), 6, 0, false)
- root.AddItem(state.pages, 0, 1, true)
- root.AddItem(footerView(), 1, 0, false)
-
- if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
- return err
- }
- return nil
-}
-
-func (s *appState) push(name string, primitive tview.Primitive) {
- s.pages.AddPage(name, primitive, true, true)
- s.stack = append(s.stack, name)
- s.pages.SwitchToPage(name)
- if menu, ok := primitive.(*Menu); ok {
- s.menus[name] = menu
- }
-}
-
-func (s *appState) pop() {
- if len(s.stack) == 0 {
- return
- }
- last := s.stack[len(s.stack)-1]
- s.pages.RemovePage(last)
- s.stack = s.stack[:len(s.stack)-1]
- if len(s.stack) == 0 {
- s.app.Stop()
- return
- }
- current := s.stack[len(s.stack)-1]
- s.pages.SwitchToPage(current)
- if menu, ok := s.menus[current]; ok {
- s.refreshMenu(current, menu)
- }
-}
-
-func (s *appState) mainMenu() tview.Primitive {
- menu := NewMenu("Menu", nil)
- refreshMainMenu(menu, s)
- menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- switch event.Key() {
- case tcell.KeyEsc:
- s.requestExit()
- return nil
- }
-
- return event
- })
-
- return menu
-}
-
-func (s *appState) refreshMenu(name string, menu *Menu) {
- switch name {
- case "main":
- refreshMainMenu(menu, s)
- case "model":
- refreshModelMenuFromState(menu, s)
- case "channel":
- refreshChannelMenuFromState(menu, s)
- }
-}
-
-func (s *appState) countChannels() (enabled int, total int) {
- c := s.config.Channels
- entries := []bool{
- c.Telegram.Enabled,
- c.Discord.Enabled,
- c.QQ.Enabled,
- c.MaixCam.Enabled,
- c.WhatsApp.Enabled,
- c.Feishu.Enabled,
- c.DingTalk.Enabled,
- c.Slack.Enabled,
- c.Matrix.Enabled,
- c.LINE.Enabled,
- c.OneBot.Enabled,
- c.WeCom.Enabled,
- c.WeComApp.Enabled,
- }
- total = len(entries)
- for _, v := range entries {
- if v {
- enabled++
- }
- }
- return enabled, total
-}
-
-func refreshMainMenuIfPresent(s *appState) {
- if menu, ok := s.menus["main"]; ok {
- refreshMainMenu(menu, s)
- }
-}
-
-func refreshMainMenu(menu *Menu, s *appState) {
- selectedModel := s.selectedModelName()
- modelReady := selectedModel != ""
- channelReady := s.hasEnabledChannel()
- enabledCount, totalChannels := s.countChannels()
- gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
-
- gatewayLabel := "Start Gateway"
- gatewayDescription := "Launch gateway for channels"
- if gatewayRunning {
- gatewayLabel = "Stop Gateway"
- gatewayDescription = "Gateway running"
- }
-
- items := []MenuItem{
- {
- Label: rootModelLabel(selectedModel),
- Description: rootModelDescription(),
- Action: func() {
- s.push("model", s.modelMenu())
- },
- MainColor: func() *tcell.Color {
- if modelReady {
- return nil
- }
- color := tcell.ColorGray
- return &color
- }(),
- },
- {
- Label: rootChannelLabel(channelReady),
- Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels),
- Action: func() {
- s.push("channel", s.channelMenu())
- },
- MainColor: func() *tcell.Color {
- if channelReady {
- return nil
- }
- color := tcell.ColorGray
- return &color
- }(),
- },
- {
- Label: "Start Talk",
- Description: "Open picoclaw agent in terminal",
- Action: func() {
- s.requestStartTalk()
- },
- Disabled: !modelReady,
- },
- {
- Label: gatewayLabel,
- Description: gatewayDescription,
- Action: func() {
- if gatewayRunning {
- s.stopGateway()
- } else {
- s.requestStartGateway()
- }
- refreshMainMenu(menu, s)
- },
- Disabled: !gatewayRunning && (!modelReady || !channelReady),
- },
- {
- Label: "View Gateway Log",
- Description: "Open gateway.log",
- Action: func() {
- s.viewGatewayLog()
- },
- },
- {
- Label: "Exit",
- Description: "Exit the TUI",
- Action: func() {
- s.requestExit()
- },
- },
- }
- menu.applyItems(items)
-}
-
-func (s *appState) applyChangesValidated() bool {
- if err := s.config.ValidateModelList(); err != nil {
- s.showMessage("Validation failed", err.Error())
- return false
- }
- if err := s.validateAgentModel(); err != nil {
- s.showMessage("Validation failed", err.Error())
- return false
- }
- if err := configstore.Save(s.config); err != nil {
- s.showMessage("Save failed", err.Error())
- return false
- }
- if data, err := os.ReadFile(s.configPath); err == nil {
- s.original = data
- s.hasOriginal = true
- _ = writeBackupConfig(s.backupPath, data)
- }
- return true
-}
-
-func (s *appState) requestExit() {
- if s.dirty {
- s.confirmApplyOrDiscard(func() {
- s.app.Stop()
- }, func() {
- s.discardChanges()
- s.app.Stop()
- })
- return
- }
- s.app.Stop()
-}
-
-func (s *appState) requestStartTalk() {
- if s.dirty {
- s.confirmApplyOrDiscard(func() {
- s.startTalk()
- }, func() {
- s.startTalk()
- })
- return
- }
- s.startTalk()
-}
-
-func (s *appState) requestStartGateway() {
- if s.dirty {
- s.confirmApplyOrDiscard(func() {
- s.startGateway()
- }, func() {
- s.startGateway()
- })
- return
- }
- s.startGateway()
-}
-
-func (s *appState) viewGatewayLog() {
- data, err := os.ReadFile(s.logPath)
- if err != nil {
- s.showMessage("Log not found", "gateway.log not found")
- return
- }
- text := tview.NewTextView()
- text.SetBorder(true).SetTitle("Gateway Log")
- text.SetText(string(data))
- text.SetDoneFunc(func(key tcell.Key) {
- s.pages.RemovePage("log")
- })
- text.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Key() == tcell.KeyEsc {
- s.pages.RemovePage("log")
- return nil
- }
- return event
- })
- s.pages.AddPage("log", text, true, true)
-}
-
-func (s *appState) selectedModelName() string {
- modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
- if modelName == "" {
- return ""
- }
- if !s.isActiveModelValid() {
- return ""
- }
- return modelName
-}
-
-func rootModelLabel(selected string) string {
- if selected == "" {
- return "Model (None)"
- }
- return "Model (" + selected + ")"
-}
-
-func rootModelDescription() string {
- return "Using SPACE to choose your model"
-}
-
-func rootChannelLabel(valid bool) string {
- if !valid {
- return "Channel (no channel enabled)"
- }
- return "Channel"
-}
-
-func (s *appState) startTalk() {
- if !s.isActiveModelValid() {
- s.showMessage("Model required", "Select a valid model before starting talk")
- return
- }
- if !s.applyChangesValidated() {
- return
- }
- s.app.Suspend(func() {
- cmd := exec.Command("picoclaw", "agent")
- cmd.Stdin = os.Stdin
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- _ = cmd.Run()
- })
-}
-
-func (s *appState) startGateway() {
- if !s.isActiveModelValid() {
- s.showMessage("Model required", "Select a valid model before starting gateway")
- return
- }
- if !s.hasEnabledChannel() {
- s.showMessage("Channel required", "Enable at least one channel before starting gateway")
- return
- }
- if !s.applyChangesValidated() {
- return
- }
- _ = stopGatewayProcess()
- cmd := exec.Command("picoclaw", "gateway")
- logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
- if err != nil {
- s.showMessage("Gateway failed", err.Error())
- return
- }
- cmd.Stdout = logFile
- cmd.Stderr = logFile
- if err := cmd.Start(); err != nil {
- s.showMessage("Gateway failed", err.Error())
- _ = logFile.Close()
- return
- }
- _ = logFile.Close()
- s.gatewayCmd = cmd
-}
-
-func (s *appState) stopGateway() {
- _ = stopGatewayProcess()
- if s.gatewayCmd != nil && s.gatewayCmd.Process != nil {
- _ = s.gatewayCmd.Process.Kill()
- }
- s.gatewayCmd = nil
-}
-
-func (s *appState) isGatewayRunning() bool {
- return isGatewayProcessRunning()
-}
-
-func (s *appState) validateAgentModel() error {
- modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
- if modelName == "" {
- return nil
- }
- _, err := s.config.GetModelConfig(modelName)
- return err
-}
-
-func (s *appState) isActiveModelValid() bool {
- modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
- if modelName == "" {
- return false
- }
- cfg, err := s.config.GetModelConfig(modelName)
- if err != nil {
- return false
- }
- hasKey := strings.TrimSpace(cfg.APIKey) != "" || strings.TrimSpace(cfg.AuthMethod) == "oauth"
- hasModel := strings.TrimSpace(cfg.Model) != ""
- return hasKey && hasModel
-}
-
-func (s *appState) hasEnabledChannel() bool {
- c := s.config.Channels
- return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
- c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
- c.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
-}
-
-func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) {
- if s.pages.HasPage("apply") {
- return
- }
- modal := tview.NewModal().
- SetText("Apply changes or discard before continuing?").
- AddButtons([]string{"Cancel", "Discard", "Apply"}).
- SetDoneFunc(func(buttonIndex int, buttonLabel string) {
- s.pages.RemovePage("apply")
- switch buttonLabel {
- case "Discard":
- s.discardChanges()
- if onDiscard != nil {
- onDiscard()
- }
- case "Apply":
- if s.applyChangesValidated() {
- s.dirty = false
- if onApply != nil {
- onApply()
- }
- }
- }
- })
- modal.SetBorder(true)
- s.pages.AddPage("apply", modal, true, true)
-}
-
-func (s *appState) discardChanges() {
- if s.hasOriginal {
- _ = writeOriginalConfig(s.configPath, s.original)
- } else {
- _ = os.Remove(s.configPath)
- }
- _ = os.Remove(s.backupPath)
- if cfg, err := configstore.Load(); err == nil && cfg != nil {
- s.config = cfg
- }
- s.dirty = false
- refreshMainMenuIfPresent(s)
-}
-
-func (s *appState) showMessage(title, message string) {
- if s.pages.HasPage("message") {
- return
- }
- modal := tview.NewModal().
- SetText(strings.TrimSpace(message)).
- AddButtons([]string{"OK"}).
- SetDoneFunc(func(_ int, _ string) {
- s.pages.RemovePage("message")
- })
- modal.SetTitle(title).SetBorder(true)
- modal.SetBackgroundColor(tview.Styles.ContrastBackgroundColor)
- modal.SetTextColor(tview.Styles.PrimaryTextColor)
- modal.SetButtonBackgroundColor(tcell.NewRGBColor(112, 102, 255))
- modal.SetButtonTextColor(tview.Styles.PrimaryTextColor)
- s.pages.AddPage("message", modal, true, true)
-}
-
-func loadOriginalConfig(path string) ([]byte, bool) {
- data, err := os.ReadFile(path)
- if err != nil {
- if os.IsNotExist(err) {
- return nil, false
- }
- return nil, false
- }
- return data, true
-}
-
-func writeOriginalConfig(path string, data []byte) error {
- return os.WriteFile(path, data, 0o600)
-}
-
-func writeBackupConfig(path string, data []byte) error {
- return os.WriteFile(path, data, 0o600)
-}
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/channel.go b/cmd/picoclaw-launcher-tui/internal/ui/channel.go
deleted file mode 100644
index 2f28af123..000000000
--- a/cmd/picoclaw-launcher-tui/internal/ui/channel.go
+++ /dev/null
@@ -1,433 +0,0 @@
-package ui
-
-import (
- "fmt"
- "strings"
-
- "github.com/gdamore/tcell/v2"
- "github.com/rivo/tview"
-
- picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
-)
-
-func (s *appState) buildChannelMenuItems() []MenuItem {
- return []MenuItem{
- channelItem(
- "Telegram",
- "Telegram bot settings",
- s.config.Channels.Telegram.Enabled,
- func() { s.push("channel-telegram", s.telegramForm()) },
- ),
- channelItem(
- "Discord",
- "Discord bot settings",
- s.config.Channels.Discord.Enabled,
- func() { s.push("channel-discord", s.discordForm()) },
- ),
- channelItem(
- "QQ",
- "QQ bot settings",
- s.config.Channels.QQ.Enabled,
- func() { s.push("channel-qq", s.qqForm()) },
- ),
- channelItem(
- "MaixCam",
- "MaixCam gateway",
- s.config.Channels.MaixCam.Enabled,
- func() { s.push("channel-maixcam", s.maixcamForm()) },
- ),
- channelItem(
- "WhatsApp",
- "WhatsApp bridge",
- s.config.Channels.WhatsApp.Enabled,
- func() { s.push("channel-whatsapp", s.whatsappForm()) },
- ),
- channelItem(
- "Feishu",
- "Feishu bot settings",
- s.config.Channels.Feishu.Enabled,
- func() { s.push("channel-feishu", s.feishuForm()) },
- ),
- channelItem(
- "DingTalk",
- "DingTalk bot settings",
- s.config.Channels.DingTalk.Enabled,
- func() { s.push("channel-dingtalk", s.dingtalkForm()) },
- ),
- channelItem(
- "Slack",
- "Slack bot settings",
- s.config.Channels.Slack.Enabled,
- func() { s.push("channel-slack", s.slackForm()) },
- ),
- channelItem(
- "Matrix",
- "Matrix bot settings",
- s.config.Channels.Matrix.Enabled,
- func() { s.push("channel-matrix", s.matrixForm()) },
- ),
- channelItem(
- "LINE",
- "LINE bot settings",
- s.config.Channels.LINE.Enabled,
- func() { s.push("channel-line", s.lineForm()) },
- ),
- channelItem(
- "OneBot",
- "OneBot settings",
- s.config.Channels.OneBot.Enabled,
- func() { s.push("channel-onebot", s.onebotForm()) },
- ),
- channelItem(
- "WeCom",
- "WeCom bot settings",
- s.config.Channels.WeCom.Enabled,
- func() { s.push("channel-wecom", s.wecomForm()) },
- ),
- channelItem(
- "WeCom App",
- "WeCom App settings",
- s.config.Channels.WeComApp.Enabled,
- func() { s.push("channel-wecomapp", s.wecomAppForm()) },
- ),
- }
-}
-
-func (s *appState) channelMenu() tview.Primitive {
- menu := NewMenu("Channels", s.buildChannelMenuItems())
- menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Key() == tcell.KeyEsc {
- s.pop()
- return nil
- }
- return event
- })
- return menu
-}
-
-func refreshChannelMenuFromState(menu *Menu, s *appState) {
- menu.applyItems(s.buildChannelMenuItems())
-}
-
-func (s *appState) telegramForm() tview.Primitive {
- cfg := &s.config.Channels.Telegram
- form := baseChannelForm("Telegram", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
- cfg.Token = strings.TrimSpace(text)
- })
- form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) {
- cfg.Proxy = strings.TrimSpace(text)
- })
- addAllowFromField(form, &cfg.AllowFrom)
- return wrapWithBack(form, s)
-}
-
-func (s *appState) discordForm() tview.Primitive {
- cfg := &s.config.Channels.Discord
- form := baseChannelForm("Discord", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
- cfg.Token = strings.TrimSpace(text)
- })
- form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) {
- cfg.MentionOnly = checked
- })
- addAllowFromField(form, &cfg.AllowFrom)
- return wrapWithBack(form, s)
-}
-
-func (s *appState) qqForm() tview.Primitive {
- cfg := &s.config.Channels.QQ
- form := baseChannelForm("QQ", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
- cfg.AppID = strings.TrimSpace(text)
- })
- form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
- cfg.AppSecret = strings.TrimSpace(text)
- })
- addAllowFromField(form, &cfg.AllowFrom)
- return wrapWithBack(form, s)
-}
-
-func (s *appState) maixcamForm() tview.Primitive {
- cfg := &s.config.Channels.MaixCam
- form := baseChannelForm("MaixCam", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("Host", cfg.Host, 64, nil, func(text string) {
- cfg.Host = strings.TrimSpace(text)
- })
- addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value })
- addAllowFromField(form, &cfg.AllowFrom)
- return wrapWithBack(form, s)
-}
-
-func (s *appState) whatsappForm() tview.Primitive {
- cfg := &s.config.Channels.WhatsApp
- form := baseChannelForm("WhatsApp", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) {
- cfg.BridgeURL = strings.TrimSpace(text)
- })
- addAllowFromField(form, &cfg.AllowFrom)
- return wrapWithBack(form, s)
-}
-
-func (s *appState) feishuForm() tview.Primitive {
- cfg := &s.config.Channels.Feishu
- form := baseChannelForm("Feishu", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
- cfg.AppID = strings.TrimSpace(text)
- })
- form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
- cfg.AppSecret = strings.TrimSpace(text)
- })
- form.AddInputField("Encrypt Key", cfg.EncryptKey, 128, nil, func(text string) {
- cfg.EncryptKey = strings.TrimSpace(text)
- })
- form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) {
- cfg.VerificationToken = strings.TrimSpace(text)
- })
- addAllowFromField(form, &cfg.AllowFrom)
- return wrapWithBack(form, s)
-}
-
-func (s *appState) dingtalkForm() tview.Primitive {
- cfg := &s.config.Channels.DingTalk
- form := baseChannelForm("DingTalk", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) {
- cfg.ClientID = strings.TrimSpace(text)
- })
- form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) {
- cfg.ClientSecret = strings.TrimSpace(text)
- })
- addAllowFromField(form, &cfg.AllowFrom)
- return wrapWithBack(form, s)
-}
-
-func (s *appState) slackForm() tview.Primitive {
- cfg := &s.config.Channels.Slack
- form := baseChannelForm("Slack", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) {
- cfg.BotToken = strings.TrimSpace(text)
- })
- form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) {
- cfg.AppToken = strings.TrimSpace(text)
- })
- addAllowFromField(form, &cfg.AllowFrom)
- return wrapWithBack(form, s)
-}
-
-func (s *appState) lineForm() tview.Primitive {
- cfg := &s.config.Channels.LINE
- form := baseChannelForm("LINE", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) {
- cfg.ChannelSecret = strings.TrimSpace(text)
- })
- form.AddInputField("Channel Access Token", cfg.ChannelAccessToken, 128, nil, func(text string) {
- cfg.ChannelAccessToken = strings.TrimSpace(text)
- })
- form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
- cfg.WebhookHost = strings.TrimSpace(text)
- })
- addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
- form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
- cfg.WebhookPath = strings.TrimSpace(text)
- })
- addAllowFromField(form, &cfg.AllowFrom)
- return wrapWithBack(form, s)
-}
-
-func (s *appState) matrixForm() tview.Primitive {
- cfg := &s.config.Channels.Matrix
- form := baseChannelForm("Matrix", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("Homeserver", cfg.Homeserver, 128, nil, func(text string) {
- cfg.Homeserver = strings.TrimSpace(text)
- })
- form.AddInputField("User ID", cfg.UserID, 128, nil, func(text string) {
- cfg.UserID = strings.TrimSpace(text)
- })
- form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
- cfg.AccessToken = strings.TrimSpace(text)
- })
- form.AddInputField("Device ID", cfg.DeviceID, 128, nil, func(text string) {
- cfg.DeviceID = strings.TrimSpace(text)
- })
- form.AddCheckbox("Join On Invite", cfg.JoinOnInvite, func(checked bool) {
- cfg.JoinOnInvite = checked
- })
- addAllowFromField(form, &cfg.AllowFrom)
- return wrapWithBack(form, s)
-}
-
-func (s *appState) onebotForm() tview.Primitive {
- cfg := &s.config.Channels.OneBot
- form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) {
- cfg.WSUrl = strings.TrimSpace(text)
- })
- form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
- cfg.AccessToken = strings.TrimSpace(text)
- })
- addIntField(
- form,
- "Reconnect Interval",
- cfg.ReconnectInterval,
- func(value int) { cfg.ReconnectInterval = value },
- )
- form.AddInputField(
- "Group Trigger Prefix",
- strings.Join(cfg.GroupTriggerPrefix, ","),
- 128,
- nil,
- func(text string) {
- cfg.GroupTriggerPrefix = splitCSV(text)
- },
- )
- addAllowFromField(form, &cfg.AllowFrom)
- return wrapWithBack(form, s)
-}
-
-func (s *appState) wecomForm() tview.Primitive {
- cfg := &s.config.Channels.WeCom
- form := baseChannelForm("WeCom", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
- cfg.Token = strings.TrimSpace(text)
- })
- form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
- cfg.EncodingAESKey = strings.TrimSpace(text)
- })
- form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) {
- cfg.WebhookURL = strings.TrimSpace(text)
- })
- form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
- cfg.WebhookHost = strings.TrimSpace(text)
- })
- addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
- form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
- cfg.WebhookPath = strings.TrimSpace(text)
- })
- addAllowFromField(form, &cfg.AllowFrom)
- addIntField(
- form,
- "Reply Timeout",
- cfg.ReplyTimeout,
- func(value int) { cfg.ReplyTimeout = value },
- )
- return wrapWithBack(form, s)
-}
-
-func (s *appState) wecomAppForm() tview.Primitive {
- cfg := &s.config.Channels.WeComApp
- form := baseChannelForm("WeCom App", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
- form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) {
- cfg.CorpID = strings.TrimSpace(text)
- })
- form.AddInputField("Corp Secret", cfg.CorpSecret, 128, nil, func(text string) {
- cfg.CorpSecret = strings.TrimSpace(text)
- })
- addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value })
- form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
- cfg.Token = strings.TrimSpace(text)
- })
- form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
- cfg.EncodingAESKey = strings.TrimSpace(text)
- })
- form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
- cfg.WebhookHost = strings.TrimSpace(text)
- })
- addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
- form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
- cfg.WebhookPath = strings.TrimSpace(text)
- })
- addAllowFromField(form, &cfg.AllowFrom)
- addIntField(
- form,
- "Reply Timeout",
- cfg.ReplyTimeout,
- func(value int) { cfg.ReplyTimeout = value },
- )
- return wrapWithBack(form, s)
-}
-
-func (s *appState) makeChannelOnEnabled(enabledPtr *bool) func(bool) {
- return func(v bool) {
- *enabledPtr = v
- s.dirty = true
- refreshMainMenuIfPresent(s)
- if menu, ok := s.menus["channel"]; ok {
- refreshChannelMenuFromState(menu, s)
- }
- }
-}
-
-func addAllowFromField(form *tview.Form, allowFrom *picoclawconfig.FlexibleStringSlice) {
- form.AddInputField("Allow From", strings.Join(*allowFrom, ","), 128, nil, func(text string) {
- *allowFrom = splitCSV(text)
- })
-}
-
-func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form {
- form := tview.NewForm()
- form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title))
- form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
- form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
- form.AddCheckbox("Enabled", enabled, func(checked bool) {
- onEnabled(checked)
- })
- return form
-}
-
-func wrapWithBack(form *tview.Form, s *appState) tview.Primitive {
- form.AddButton("Back", func() {
- s.pop()
- })
- form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Key() == tcell.KeyEsc {
- s.pop()
- return nil
- }
- return event
- })
- return form
-}
-
-func splitCSV(input string) picoclawconfig.FlexibleStringSlice {
- parts := strings.Split(strings.TrimSpace(input), ",")
- cleaned := make([]string, 0, len(parts))
- for _, part := range parts {
- value := strings.TrimSpace(part)
- if value == "" {
- continue
- }
- cleaned = append(cleaned, value)
- }
- return cleaned
-}
-
-func addIntField(form *tview.Form, label string, value int, onChange func(int)) {
- form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
- var parsed int
- if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
- onChange(parsed)
- }
- })
-}
-
-func addInt64Field(form *tview.Form, label string, value int64, onChange func(int64)) {
- form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
- var parsed int64
- if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
- onChange(parsed)
- }
- })
-}
-
-func channelItem(label, description string, enabled bool, action MenuAction) MenuItem {
- item := MenuItem{
- Label: label,
- Description: description,
- Action: action,
- }
- if !enabled {
- color := tcell.ColorGray
- item.MainColor = &color
- }
- return item
-}
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go b/cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go
deleted file mode 100644
index bc874f7f2..000000000
--- a/cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go
+++ /dev/null
@@ -1,16 +0,0 @@
-//go:build !windows
-// +build !windows
-
-package ui
-
-import "os/exec"
-
-func isGatewayProcessRunning() bool {
- cmd := exec.Command("sh", "-c", "pgrep -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
- return cmd.Run() == nil
-}
-
-func stopGatewayProcess() error {
- cmd := exec.Command("sh", "-c", "pkill -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
- return cmd.Run()
-}
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go b/cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go
deleted file mode 100644
index 7067a5c13..000000000
--- a/cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go
+++ /dev/null
@@ -1,16 +0,0 @@
-//go:build windows
-// +build windows
-
-package ui
-
-import "os/exec"
-
-func isGatewayProcessRunning() bool {
- cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq picoclaw.exe")
- return cmd.Run() == nil
-}
-
-func stopGatewayProcess() error {
- cmd := exec.Command("taskkill", "/F", "/IM", "picoclaw.exe")
- return cmd.Run()
-}
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/menu.go b/cmd/picoclaw-launcher-tui/internal/ui/menu.go
deleted file mode 100644
index 9f2132c5a..000000000
--- a/cmd/picoclaw-launcher-tui/internal/ui/menu.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package ui
-
-import (
- "github.com/gdamore/tcell/v2"
- "github.com/rivo/tview"
-)
-
-type MenuAction func()
-
-type MenuItem struct {
- Label string
- Description string
- Action MenuAction
- Disabled bool
- MainColor *tcell.Color
- DescColor *tcell.Color
-}
-
-type Menu struct {
- *tview.Table
- items []MenuItem
-}
-
-func NewMenu(title string, items []MenuItem) *Menu {
- table := tview.NewTable().SetSelectable(true, false)
- table.SetBorder(true).SetTitle(title)
- table.SetBorders(false)
- menu := &Menu{Table: table, items: items}
- menu.applyItems(items)
- menu.SetSelectedFunc(func(row, _ int) {
- if row < 0 || row >= len(menu.items) {
- return
- }
- item := menu.items[row]
- if item.Disabled || item.Action == nil {
- return
- }
- item.Action()
- })
- menu.SetSelectedStyle(
- tcell.StyleDefault.Foreground(tview.Styles.InverseTextColor).
- Background(tcell.NewRGBColor(189, 147, 249)),
- )
- return menu
-}
-
-func (m *Menu) applyItems(items []MenuItem) {
- m.items = items
- m.Clear()
- for row, item := range items {
- label := item.Label
- if item.Disabled && label != "" {
- label = label + " (disabled)"
- }
- left := tview.NewTableCell(label)
- right := tview.NewTableCell(item.Description).SetAlign(tview.AlignRight)
- if item.MainColor != nil {
- left.SetTextColor(*item.MainColor)
- }
- if item.DescColor != nil {
- right.SetTextColor(*item.DescColor)
- } else {
- right.SetTextColor(tview.Styles.TertiaryTextColor)
- }
- if item.Disabled {
- left.SetTextColor(tcell.ColorGray)
- right.SetTextColor(tcell.ColorGray)
- }
- m.SetCell(row, 0, left)
- m.SetCell(row, 1, right)
- }
-}
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/model.go b/cmd/picoclaw-launcher-tui/internal/ui/model.go
deleted file mode 100644
index 698502058..000000000
--- a/cmd/picoclaw-launcher-tui/internal/ui/model.go
+++ /dev/null
@@ -1,399 +0,0 @@
-package ui
-
-import (
- "fmt"
- "io"
- "net/http"
- "strings"
- "time"
-
- "github.com/gdamore/tcell/v2"
- "github.com/rivo/tview"
-
- picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
-)
-
-func (s *appState) modelMenu() tview.Primitive {
- items := make([]MenuItem, 0, 1+len(s.config.ModelList))
- currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
- for i := range s.config.ModelList {
- index := i
- model := s.config.ModelList[i]
- isValid := isModelValid(model)
- desc := model.APIBase
- if desc == "" {
- desc = model.AuthMethod
- }
- if desc == "" {
- desc = "api_key required"
- }
- label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
- if model.ModelName == currentModel && currentModel != "" {
- label = "* " + label
- }
- isSelected := model.ModelName == currentModel && currentModel != ""
- items = append(items, MenuItem{
- Label: label,
- Description: desc,
- MainColor: modelStatusColor(isValid, isSelected),
- Action: func() {
- s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
- },
- })
- }
- // Add model entry appended at the end so the models map to rows 1..N
- items = append(items,
- MenuItem{
- Label: "**Add model**",
- Description: "Append a new model entry",
- Action: func() {
- newName := s.nextAvailableModelName("new-model")
- s.addModel(
- picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
- )
- s.push(
- fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
- s.modelForm(len(s.config.ModelList)-1),
- )
- },
- },
- )
-
- menu := NewMenu("Models", items)
- menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Key() == tcell.KeyEsc {
- s.pop()
- return nil
- }
-
- if event.Rune() == ' ' {
- row, _ := menu.GetSelection()
- if row >= 0 && row < len(s.config.ModelList) {
- model := s.config.ModelList[row]
- if !isModelValid(model) {
- s.showMessage(
- "Invalid model",
- "Select a model with api_key or oauth auth_method",
- )
- return nil
- }
- s.config.Agents.Defaults.Model = model.ModelName
- s.dirty = true
- refreshModelMenu(menu, s.config.Agents.Defaults.Model, s.config.ModelList)
- refreshMainMenuIfPresent(s)
- }
- return nil
- }
- return event
- })
- return menu
-}
-
-func (s *appState) modelForm(index int) tview.Primitive {
- model := &s.config.ModelList[index]
- form := tview.NewForm()
- form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
-
- addInput(form, "Model Name", model.ModelName, func(value string) {
- if value == "" {
- s.showMessage("Invalid model name", "Model Name cannot be empty")
- return
- }
- if s.modelNameExists(value, index) {
- s.showMessage("Duplicate model name", fmt.Sprintf("Model Name '%s' already exists", value))
- return
- }
- oldName := model.ModelName
- model.ModelName = value
- if s.config.Agents.Defaults.Model == oldName {
- s.config.Agents.Defaults.Model = value
- }
- s.dirty = true
- form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
- refreshMainMenuIfPresent(s)
- if menu, ok := s.menus["model"]; ok {
- refreshModelMenuFromState(menu, s)
- }
- })
- addInput(form, "Model", model.Model, func(value string) {
- model.Model = value
- s.dirty = true
- refreshMainMenuIfPresent(s)
- if menu, ok := s.menus["model"]; ok {
- refreshModelMenuFromState(menu, s)
- }
- })
- addInput(form, "API Base", model.APIBase, func(value string) {
- model.APIBase = value
- s.dirty = true
- refreshMainMenuIfPresent(s)
- if menu, ok := s.menus["model"]; ok {
- refreshModelMenuFromState(menu, s)
- }
- })
- addInput(form, "API Key", model.APIKey, func(value string) {
- model.APIKey = value
- s.dirty = true
- refreshMainMenuIfPresent(s)
- if menu, ok := s.menus["model"]; ok {
- refreshModelMenuFromState(menu, s)
- }
- })
- addInput(form, "Proxy", model.Proxy, func(value string) {
- model.Proxy = value
- })
- addInput(form, "Auth Method", model.AuthMethod, func(value string) {
- model.AuthMethod = value
- s.dirty = true
- refreshMainMenuIfPresent(s)
- if menu, ok := s.menus["model"]; ok {
- refreshModelMenuFromState(menu, s)
- }
- })
- addInput(form, "Connect Mode", model.ConnectMode, func(value string) {
- model.ConnectMode = value
- })
- addInput(form, "Workspace", model.Workspace, func(value string) {
- model.Workspace = value
- })
- addInput(form, "Max Tokens Field", model.MaxTokensField, func(value string) {
- model.MaxTokensField = value
- })
- addIntInput(form, "RPM", model.RPM, func(value int) {
- model.RPM = value
- })
- addIntInput(form, "Request Timeout", model.RequestTimeout, func(value int) {
- model.RequestTimeout = value
- })
-
- form.AddButton("Delete", func() {
- pageName := "confirm-delete-model"
- if s.pages.HasPage(pageName) {
- return
- }
- modal := tview.NewModal().
- SetText("Are you sure you want to delete this model?").
- AddButtons([]string{"Cancel", "Delete"}).
- SetDoneFunc(func(buttonIndex int, buttonLabel string) {
- s.pages.RemovePage(pageName)
- if buttonLabel == "Delete" {
- s.deleteModel(index)
- }
- })
- modal.SetTitle("Confirm Delete").SetBorder(true)
- s.pages.AddPage(pageName, modal, true, true)
- })
- form.AddButton("Test", func() {
- s.testModel(model)
- })
- form.AddButton("Back", func() {
- s.pop()
- })
-
- form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- if event.Key() == tcell.KeyEsc {
- s.pop()
- return nil
- }
- return event
- })
- return form
-}
-
-func addInput(form *tview.Form, label, value string, onChange func(string)) {
- form.AddInputField(label, value, 128, nil, func(text string) {
- onChange(strings.TrimSpace(text))
- })
-}
-
-func addIntInput(form *tview.Form, label string, value int, onChange func(int)) {
- form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
- var parsed int
- if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
- onChange(parsed)
- }
- })
-}
-
-func (s *appState) addModel(model picoclawconfig.ModelConfig) {
- s.config.ModelList = append(s.config.ModelList, model)
-}
-
-func (s *appState) deleteModel(index int) {
- if index < 0 || index >= len(s.config.ModelList) {
- return
- }
- s.config.ModelList = append(s.config.ModelList[:index], s.config.ModelList[index+1:]...)
- s.pop()
-}
-
-func modelStatusColor(valid bool, selected bool) *tcell.Color {
- if valid {
- color := tview.Styles.PrimaryTextColor
- return &color
- }
- color := tcell.ColorGray
- return &color
-}
-
-func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
- for i, model := range models {
- row := i
- label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
- isValid := isModelValid(model)
- if model.ModelName == currentModel && currentModel != "" {
- label = "* " + label
- }
- cell := menu.GetCell(row, 0)
- if cell != nil {
- cell.SetText(label)
- isSelected := model.ModelName == currentModel && currentModel != ""
- color := modelStatusColor(isValid, isSelected)
- if color != nil {
- cell.SetTextColor(*color)
- }
- }
- }
-}
-
-func refreshModelMenuFromState(menu *Menu, s *appState) {
- items := make([]MenuItem, 0, 1+len(s.config.ModelList))
- currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
- for i := range s.config.ModelList {
- index := i
- model := s.config.ModelList[i]
- isValid := isModelValid(model)
- desc := model.APIBase
- if desc == "" {
- desc = model.AuthMethod
- }
- if desc == "" {
- desc = "api_key required"
- }
- label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
- if model.ModelName == currentModel && currentModel != "" {
- label = "* " + label
- }
- isSelected := model.ModelName == currentModel && currentModel != ""
- items = append(items, MenuItem{
- Label: label,
- Description: desc,
- MainColor: modelStatusColor(isValid, isSelected),
- Action: func() {
- s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
- },
- })
- }
- items = append(items,
- MenuItem{
- Label: "**Add Model**",
- Description: "Append a new model entry",
- Action: func() {
- newName := s.nextAvailableModelName("new-model")
- s.addModel(
- picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
- )
- s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
- },
- },
- )
- menu.applyItems(items)
-}
-
-func isModelValid(model picoclawconfig.ModelConfig) bool {
- hasKey := strings.TrimSpace(model.APIKey) != "" ||
- strings.TrimSpace(model.AuthMethod) == "oauth"
- hasModel := strings.TrimSpace(model.Model) != ""
- return hasKey && hasModel
-}
-
-func (s *appState) modelNameExists(name string, excludeIndex int) bool {
- target := strings.TrimSpace(name)
- if target == "" {
- return false
- }
- for i := range s.config.ModelList {
- if i == excludeIndex {
- continue
- }
- if strings.TrimSpace(s.config.ModelList[i].ModelName) == target {
- return true
- }
- }
- return false
-}
-
-func (s *appState) nextAvailableModelName(base string) string {
- name := strings.TrimSpace(base)
- if name == "" {
- name = "new-model"
- }
- if !s.modelNameExists(name, -1) {
- return name
- }
- for i := 2; ; i++ {
- candidate := fmt.Sprintf("%s-%d", name, i)
- if !s.modelNameExists(candidate, -1) {
- return candidate
- }
- }
-}
-
-func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
- if model == nil {
- return
- }
- if strings.TrimSpace(model.APIKey) == "" {
- s.showMessage("Missing API Key", "Set api_key before testing")
- return
- }
- base := strings.TrimSpace(model.APIBase)
- if base == "" {
- s.showMessage("Missing API Base", "Set api_base before testing")
- return
- }
- modelID := strings.TrimSpace(model.Model)
- if modelID == "" {
- s.showMessage("Missing Model", "Set model before testing")
- return
- }
- if !strings.HasPrefix(modelID, "openai/") {
- s.showMessage("Unsupported model", "Only openai/* models are supported for test")
- return
- }
- modelName := strings.TrimPrefix(modelID, "openai/")
- endpoint := strings.TrimRight(base, "/") + "/chat/completions"
-
- payload := fmt.Sprintf(
- `{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}`,
- modelName,
- )
- client := &http.Client{Timeout: 10 * time.Second}
- request, err := http.NewRequest("POST", endpoint, strings.NewReader(payload))
- if err != nil {
- s.showMessage("Test failed", err.Error())
- return
- }
- request.Header.Set("Content-Type", "application/json")
- request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey))
-
- resp, err := client.Do(request)
- if err != nil {
- s.showMessage("Test failed", err.Error())
- return
- }
- defer resp.Body.Close()
- if resp.StatusCode >= 200 && resp.StatusCode < 300 {
- s.showMessage("Test OK", resp.Status)
- return
- }
- body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
- if err != nil {
- s.showMessage("Test failed", fmt.Sprintf("failed to read response: %v", err))
- return
- }
- s.showMessage(
- "Test failed",
- fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
- )
-}
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/style.go b/cmd/picoclaw-launcher-tui/internal/ui/style.go
deleted file mode 100644
index da3c3526d..000000000
--- a/cmd/picoclaw-launcher-tui/internal/ui/style.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package ui
-
-import (
- "github.com/gdamore/tcell/v2"
- "github.com/rivo/tview"
-)
-
-const (
- colorBlue = "[#3e5db9]"
- colorRed = "[#d54646]"
- banner = "\r\n[::b]" +
- colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
- colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
- colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
- colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
- colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
- colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
- "[:]"
-)
-
-func applyStyles() {
- tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22)
- tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53)
- tview.Styles.MoreContrastBackgroundColor = tcell.NewRGBColor(18, 18, 32)
- tview.Styles.BorderColor = tcell.NewRGBColor(112, 102, 255)
- tview.Styles.TitleColor = tcell.NewRGBColor(255, 121, 198)
- tview.Styles.GraphicsColor = tcell.NewRGBColor(139, 233, 253)
- tview.Styles.PrimaryTextColor = tcell.NewRGBColor(241, 250, 255)
- tview.Styles.SecondaryTextColor = tcell.NewRGBColor(80, 250, 123)
- tview.Styles.TertiaryTextColor = tcell.NewRGBColor(139, 233, 253)
- tview.Styles.InverseTextColor = tcell.NewRGBColor(12, 13, 22)
- tview.Styles.ContrastSecondaryTextColor = tcell.NewRGBColor(189, 147, 249)
-}
-
-func bannerView() *tview.TextView {
- text := tview.NewTextView()
- text.SetDynamicColors(true)
- text.SetTextAlign(tview.AlignCenter)
- text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
- text.SetText(banner)
- text.SetBorder(false)
- return text
-}
-
-const footerText = "Esc: Back/Exit | Enter: Enter | ←↓↑→ : Move | Space: Select | Tab/Shift+Tab: Switch"
-
-func footerView() *tview.TextView {
- text := tview.NewTextView()
- text.SetTextAlign(tview.AlignCenter)
- text.SetText(footerText)
- text.SetBackgroundColor(tview.Styles.MoreContrastBackgroundColor)
- text.SetTextColor(tview.Styles.PrimaryTextColor)
- text.SetBorder(false)
- return text
-}
diff --git a/cmd/picoclaw-launcher-tui/main.go b/cmd/picoclaw-launcher-tui/main.go
deleted file mode 100644
index 0e8cce415..000000000
--- a/cmd/picoclaw-launcher-tui/main.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package main
-
-import (
- "fmt"
- "os"
-
- "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui"
-)
-
-func main() {
- if err := ui.Run(); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
-}
From 5a199ec9937bcc1d0e5172b1b9f4869de328a5ce Mon Sep 17 00:00:00 2001
From: taorye
Date: Fri, 20 Mar 2026 11:54:58 +0800
Subject: [PATCH 29/44] feat: implement TUI configuration and user management
for picoclaw-launcher-tui
---
cmd/picoclaw-launcher-tui/config/config.go | 159 ++++++++++++++++++++
cmd/picoclaw-launcher-tui/main.go | 33 +++++
cmd/picoclaw-launcher-tui/ui/app.go | 123 ++++++++++++++++
cmd/picoclaw-launcher-tui/ui/home.go | 43 ++++++
cmd/picoclaw-launcher-tui/ui/models.go | 143 ++++++++++++++++++
cmd/picoclaw-launcher-tui/ui/schemes.go | 147 +++++++++++++++++++
cmd/picoclaw-launcher-tui/ui/users.go | 161 +++++++++++++++++++++
go.mod | 1 +
go.sum | 2 +
9 files changed, 812 insertions(+)
create mode 100644 cmd/picoclaw-launcher-tui/config/config.go
create mode 100644 cmd/picoclaw-launcher-tui/main.go
create mode 100644 cmd/picoclaw-launcher-tui/ui/app.go
create mode 100644 cmd/picoclaw-launcher-tui/ui/home.go
create mode 100644 cmd/picoclaw-launcher-tui/ui/models.go
create mode 100644 cmd/picoclaw-launcher-tui/ui/schemes.go
create mode 100644 cmd/picoclaw-launcher-tui/ui/users.go
diff --git a/cmd/picoclaw-launcher-tui/config/config.go b/cmd/picoclaw-launcher-tui/config/config.go
new file mode 100644
index 000000000..15c81f90a
--- /dev/null
+++ b/cmd/picoclaw-launcher-tui/config/config.go
@@ -0,0 +1,159 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+// Package config provides types and I/O for ~/.picoclaw/tui.toml.
+package config
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/BurntSushi/toml"
+ "github.com/sipeed/picoclaw/pkg/fileutil"
+)
+
+// DefaultConfigPath returns the default path to the tui.toml config file.
+func DefaultConfigPath() string {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ home = "."
+ }
+ return filepath.Join(home, ".picoclaw", "tui.toml")
+}
+
+// TUIConfig is the top-level structure of ~/.picoclaw/tui.toml.
+type TUIConfig struct {
+ Version string `toml:"version"`
+ Model Model `toml:"model"`
+ Provider Provider `toml:"provider"`
+}
+
+type Model struct {
+ Type string `toml:"type"` // "provider" (default) | "manual"
+}
+
+type Provider struct {
+ Schemes []Scheme `toml:"schemes"`
+ Users []User `toml:"users"`
+ Current ProviderCurrent `toml:"current"`
+}
+
+type Scheme struct {
+ Name string `toml:"name"` // unique key
+ BaseURL string `toml:"baseURL"` // required
+ Type string `toml:"type"` // "openai-compatible" (default) | "anthropic"
+}
+
+type User struct {
+ Name string `toml:"name"`
+ Scheme string `toml:"scheme"` // references Scheme.Name; (Name+Scheme) is unique
+ Type string `toml:"type"` // "key" (default) | "OAuth"
+ Key string `toml:"key"`
+}
+
+type ProviderCurrent struct {
+ Scheme string `toml:"scheme"` // references Scheme.Name
+ User string `toml:"user"` // references User.Name where User.Scheme == Scheme
+ Model string `toml:"model"` // from GET /models
+}
+
+// DefaultConfig returns a minimal valid TUIConfig.
+func DefaultConfig() *TUIConfig {
+ return &TUIConfig{
+ Version: "1.0",
+ Model: Model{Type: "provider"},
+ Provider: Provider{
+ Schemes: []Scheme{},
+ Users: []User{},
+ Current: ProviderCurrent{},
+ },
+ }
+}
+
+// Load reads the TUI config from path. Returns a default config if the file does not exist.
+func Load(path string) (*TUIConfig, error) {
+ data, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ return DefaultConfig(), nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("failed to read config file %q: %w", path, err)
+ }
+
+ cfg := DefaultConfig()
+ if _, err := toml.Decode(string(data), cfg); err != nil {
+ return nil, fmt.Errorf("failed to parse config file %q: %w", path, err)
+ }
+
+ applyDefaults(cfg)
+ return cfg, nil
+}
+
+// Save writes cfg to path atomically (safe for flash / SD storage).
+func Save(path string, cfg *TUIConfig) error {
+ var buf bytes.Buffer
+ enc := toml.NewEncoder(&buf)
+ if err := enc.Encode(cfg); err != nil {
+ return fmt.Errorf("failed to encode config: %w", err)
+ }
+ if err := fileutil.WriteFileAtomic(path, buf.Bytes(), 0o600); err != nil {
+ return fmt.Errorf("failed to write config file %q: %w", path, err)
+ }
+ return nil
+}
+
+func applyDefaults(cfg *TUIConfig) {
+ if cfg.Version == "" {
+ cfg.Version = "1.0"
+ }
+ if cfg.Model.Type == "" {
+ cfg.Model.Type = "provider"
+ }
+ for i := range cfg.Provider.Schemes {
+ if cfg.Provider.Schemes[i].Type == "" {
+ cfg.Provider.Schemes[i].Type = "openai-compatible"
+ }
+ }
+ for i := range cfg.Provider.Users {
+ if cfg.Provider.Users[i].Type == "" {
+ cfg.Provider.Users[i].Type = "key"
+ }
+ }
+}
+
+// SchemeByName returns the first Scheme whose Name matches, or nil.
+func (p *Provider) SchemeByName(name string) *Scheme {
+ for i := range p.Schemes {
+ if p.Schemes[i].Name == name {
+ return &p.Schemes[i]
+ }
+ }
+ return nil
+}
+
+// UsersForScheme returns all users whose Scheme field matches schemeName.
+func (p *Provider) UsersForScheme(schemeName string) []User {
+ var out []User
+ for _, u := range p.Users {
+ if u.Scheme == schemeName {
+ out = append(out, u)
+ }
+ }
+ return out
+}
+
+func (cfg *TUIConfig) CurrentModelLabel() string {
+ cur := cfg.Provider.Current
+ if cur.Model == "" {
+ return "(not configured)"
+ }
+ label := cur.Scheme
+ if label != "" {
+ label += " / "
+ }
+ return label + cur.Model
+}
diff --git a/cmd/picoclaw-launcher-tui/main.go b/cmd/picoclaw-launcher-tui/main.go
new file mode 100644
index 000000000..3d7e62b08
--- /dev/null
+++ b/cmd/picoclaw-launcher-tui/main.go
@@ -0,0 +1,33 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package main
+
+import (
+ "fmt"
+ "os"
+
+ tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
+ "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui"
+)
+
+func main() {
+ configPath := tuicfg.DefaultConfigPath()
+ if len(os.Args) > 1 {
+ configPath = os.Args[1]
+ }
+
+ cfg, err := tuicfg.Load(configPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
+ os.Exit(1)
+ }
+
+ app := ui.New(cfg, configPath)
+ if err := app.Run(); err != nil {
+ fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
+ os.Exit(1)
+ }
+}
diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go
new file mode 100644
index 000000000..c642a1753
--- /dev/null
+++ b/cmd/picoclaw-launcher-tui/ui/app.go
@@ -0,0 +1,123 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package ui
+
+import (
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+ tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
+)
+
+// App is the root TUI application.
+type App struct {
+ tapp *tview.Application
+ pages *tview.Pages
+ pageStack []string
+ cfg *tuicfg.TUIConfig
+ configPath string
+ homeRefreshFn func()
+}
+
+// New creates and wires up the TUI application.
+func New(cfg *tuicfg.TUIConfig, configPath string) *App {
+ a := &App{
+ tapp: tview.NewApplication(),
+ pages: tview.NewPages(),
+ pageStack: []string{},
+ cfg: cfg,
+ configPath: configPath,
+ }
+
+ a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ if event.Key() == tcell.KeyEscape {
+ return a.goBack()
+ }
+ return event
+ })
+
+ a.buildPages()
+ return a
+}
+
+// Run starts the TUI event loop.
+func (a *App) Run() error {
+ return a.tapp.SetRoot(a.pages, true).EnableMouse(true).Run()
+}
+
+func (a *App) buildPages() {
+ a.pages.AddPage("home", a.newHomePage(), true, true)
+ a.pageStack = []string{"home"}
+}
+
+func (a *App) navigateTo(name string, page tview.Primitive) {
+ a.pages.AddPage(name, page, true, false)
+ a.pageStack = append(a.pageStack, name)
+ a.pages.SwitchToPage(name)
+}
+
+func (a *App) goBack() *tcell.EventKey {
+ if len(a.pageStack) <= 1 {
+ return nil
+ }
+ a.pageStack = a.pageStack[:len(a.pageStack)-1]
+ prev := a.pageStack[len(a.pageStack)-1]
+ if prev == "home" && a.homeRefreshFn != nil {
+ a.homeRefreshFn()
+ }
+ a.pages.SwitchToPage(prev)
+ return nil
+}
+
+func (a *App) showModal(name string, primitive tview.Primitive) {
+ a.pages.AddPage(name, primitive, true, true)
+}
+
+func (a *App) hideModal(name string) {
+ a.pages.HidePage(name)
+ a.pages.RemovePage(name)
+}
+
+func (a *App) save() {
+ _ = tuicfg.Save(a.configPath, a.cfg)
+}
+
+func (a *App) showError(msg string) {
+ modal := tview.NewModal().
+ SetText("Error: " + msg).
+ AddButtons([]string{"OK"}).
+ SetDoneFunc(func(_ int, _ string) {
+ a.hideModal("error")
+ })
+ a.showModal("error", modal)
+}
+
+func (a *App) confirmDelete(label string, onConfirm func()) {
+ modal := tview.NewModal().
+ SetText("Delete " + label + "?\nThis cannot be undone.").
+ AddButtons([]string{"Delete", "Cancel"}).
+ SetDoneFunc(func(_ int, buttonLabel string) {
+ a.hideModal("confirm-delete")
+ if buttonLabel == "Delete" {
+ onConfirm()
+ }
+ })
+ a.showModal("confirm-delete", modal)
+}
+
+func centeredForm(form *tview.Form, width, height int) tview.Primitive {
+ return tview.NewGrid().
+ SetColumns(0, width, 0).
+ SetRows(0, height, 0).
+ AddItem(form, 1, 1, 1, 1, 0, 0, true)
+}
+
+func hintBar(text string) *tview.TextView {
+ tv := tview.NewTextView().
+ SetText(text).
+ SetTextAlign(tview.AlignCenter)
+ tv.SetBackgroundColor(tcell.ColorDarkBlue)
+ return tv
+}
diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go
new file mode 100644
index 000000000..6235a2c8e
--- /dev/null
+++ b/cmd/picoclaw-launcher-tui/ui/home.go
@@ -0,0 +1,43 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package ui
+
+import (
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+func (a *App) newHomePage() tview.Primitive {
+ list := tview.NewList()
+ list.SetBorder(true).SetTitle(" picoclaw-launcher-tui ")
+
+ rebuildList := func() {
+ sel := list.GetCurrentItem()
+ list.Clear()
+ list.AddItem("model: "+a.cfg.CurrentModelLabel(), "Enter to configure", 'm', func() {
+ a.pages.RemovePage("schemes")
+ a.navigateTo("schemes", a.newSchemesPage())
+ })
+ list.AddItem("Quit", "", 'q', func() { a.tapp.Stop() })
+ if sel > 0 && sel < list.GetItemCount() {
+ list.SetCurrentItem(sel)
+ }
+ }
+ rebuildList()
+
+ a.homeRefreshFn = rebuildList
+
+ list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ return event
+ })
+
+ footer := hintBar(" Enter: select q: quit ")
+
+ return tview.NewFlex().
+ SetDirection(tview.FlexRow).
+ AddItem(list, 0, 1, true).
+ AddItem(footer, 1, 0, false)
+}
diff --git a/cmd/picoclaw-launcher-tui/ui/models.go b/cmd/picoclaw-launcher-tui/ui/models.go
new file mode 100644
index 000000000..5e102d94c
--- /dev/null
+++ b/cmd/picoclaw-launcher-tui/ui/models.go
@@ -0,0 +1,143 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package ui
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+ tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
+)
+
+type modelsAPIResponse struct {
+ Data []modelEntry `json:"data"`
+}
+
+type modelEntry struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+}
+
+func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive {
+ table := tview.NewTable().
+ SetBorders(false).
+ SetSelectable(true, false).
+ SetFixed(0, 0)
+ table.SetBorder(true).SetTitle(fmt.Sprintf(" Models %s / %s ", schemeName, userName))
+
+ var modelIDs []string
+
+ status := tview.NewTextView().
+ SetTextAlign(tview.AlignCenter).
+ SetDynamicColors(true).
+ SetText("[yellow]Fetching models…[-]")
+
+ footer := hintBar(" Enter: select ESC: back ")
+
+ flex := tview.NewFlex().
+ SetDirection(tview.FlexRow).
+ AddItem(status, 1, 0, false).
+ AddItem(table, 0, 1, false).
+ AddItem(footer, 1, 0, false)
+
+ apiKey := a.resolveKey(schemeName, userName)
+
+ go func() {
+ entries, err := fetchModels(baseURL, apiKey)
+ a.tapp.QueueUpdateDraw(func() {
+ if err != nil {
+ status.SetText(fmt.Sprintf("[red]Error: %s[-]", err.Error()))
+ table.SetCell(0, 0, tview.NewTableCell("(failed to load models)"))
+ a.tapp.SetFocus(table)
+ return
+ }
+ if len(entries) == 0 {
+ status.SetText("[yellow]No models returned[-]")
+ table.SetCell(0, 0, tview.NewTableCell("(no models available)"))
+ a.tapp.SetFocus(table)
+ return
+ }
+
+ status.SetText(fmt.Sprintf("[green]%d model(s) loaded[-]", len(entries)))
+ for i, m := range entries {
+ modelIDs = append(modelIDs, m.ID)
+ table.SetCell(i, 0,
+ tview.NewTableCell(fmt.Sprintf("%3d", i+1)).
+ SetAlign(tview.AlignRight).
+ SetTextColor(tcell.ColorGray).
+ SetSelectable(false),
+ )
+ table.SetCell(i, 1,
+ tview.NewTableCell(" "+m.ID).
+ SetAlign(tview.AlignLeft).
+ SetExpansion(1),
+ )
+ }
+ a.tapp.SetFocus(table)
+ })
+ }()
+
+ table.SetSelectedFunc(func(row, _ int) {
+ if row < 0 || row >= len(modelIDs) {
+ return
+ }
+ a.cfg.Provider.Current = tuicfg.ProviderCurrent{
+ Scheme: schemeName,
+ User: userName,
+ Model: modelIDs[row],
+ }
+ a.save()
+ a.goBack()
+ })
+
+ return flex
+}
+
+func (a *App) resolveKey(schemeName, userName string) string {
+ for _, u := range a.cfg.Provider.Users {
+ if u.Scheme == schemeName && u.Name == userName {
+ return u.Key
+ }
+ }
+ return ""
+}
+
+func fetchModels(baseURL, apiKey string) ([]modelEntry, error) {
+ url := strings.TrimRight(baseURL, "/") + "/models"
+
+ client := &http.Client{Timeout: 15 * time.Second}
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("build request: %w", err)
+ }
+ if apiKey != "" {
+ req.Header.Set("Authorization", "Bearer "+apiKey)
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+
+ var result modelsAPIResponse
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("decode response: %w", err)
+ }
+ return result.Data, nil
+}
diff --git a/cmd/picoclaw-launcher-tui/ui/schemes.go b/cmd/picoclaw-launcher-tui/ui/schemes.go
new file mode 100644
index 000000000..eec3bda7c
--- /dev/null
+++ b/cmd/picoclaw-launcher-tui/ui/schemes.go
@@ -0,0 +1,147 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package ui
+
+import (
+ "fmt"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+ tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
+)
+
+func (a *App) newSchemesPage() tview.Primitive {
+ list := tview.NewList()
+ list.SetBorder(true).SetTitle(" Provider Schemes (a:add e:edit d:delete Enter:users) ")
+
+ rebuild := func() {
+ sel := list.GetCurrentItem()
+ list.Clear()
+ for _, s := range a.cfg.Provider.Schemes {
+ name := s.Name
+ list.AddItem(
+ fmt.Sprintf("%s · %s [%s]", s.Name, s.BaseURL, s.Type),
+ "",
+ 0,
+ func() {
+ a.pages.RemovePage("users")
+ a.navigateTo("users", a.newUsersPage(name))
+ },
+ )
+ }
+ if sel >= 0 && sel < list.GetItemCount() {
+ list.SetCurrentItem(sel)
+ }
+ }
+ rebuild()
+
+ list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ switch event.Rune() {
+ case 'a':
+ a.showSchemeForm(nil, func(s tuicfg.Scheme) {
+ a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s)
+ a.save()
+ rebuild()
+ })
+ return nil
+ case 'e':
+ idx := list.GetCurrentItem()
+ if idx < 0 || idx >= len(a.cfg.Provider.Schemes) {
+ return nil
+ }
+ orig := a.cfg.Provider.Schemes[idx]
+ a.showSchemeForm(&orig, func(s tuicfg.Scheme) {
+ a.cfg.Provider.Schemes[idx] = s
+ a.save()
+ rebuild()
+ })
+ return nil
+ case 'd':
+ idx := list.GetCurrentItem()
+ if idx < 0 || idx >= len(a.cfg.Provider.Schemes) {
+ return nil
+ }
+ name := a.cfg.Provider.Schemes[idx].Name
+ a.confirmDelete(fmt.Sprintf("scheme %q", name), func() {
+ schemes := a.cfg.Provider.Schemes
+ a.cfg.Provider.Schemes = append(schemes[:idx], schemes[idx+1:]...)
+ a.save()
+ rebuild()
+ })
+ return nil
+ }
+ return event
+ })
+
+ footer := hintBar(" Enter: users a: add e: edit d: delete ESC: back ")
+
+ return tview.NewFlex().
+ SetDirection(tview.FlexRow).
+ AddItem(list, 0, 1, true).
+ AddItem(footer, 1, 0, false)
+}
+
+func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) {
+ name := ""
+ baseURL := ""
+ schemeType := "openai-compatible"
+ title := " Add Scheme "
+
+ if existing != nil {
+ name = existing.Name
+ baseURL = existing.BaseURL
+ schemeType = existing.Type
+ title = " Edit Scheme "
+ }
+
+ typeOptions := []string{"openai-compatible", "anthropic"}
+ typeIdx := 0
+ for i, t := range typeOptions {
+ if t == schemeType {
+ typeIdx = i
+ break
+ }
+ }
+
+ form := tview.NewForm()
+
+ var nameField *tview.InputField
+
+ form.
+ AddInputField("Name", name, 40, nil, func(text string) { name = text }).
+ AddInputField("Base URL", baseURL, 60, nil, func(text string) { baseURL = text }).
+ AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }).
+ AddButton("Save", func() {
+ _ = nameField
+ if name == "" {
+ a.showError("Name is required")
+ return
+ }
+ if baseURL == "" {
+ a.showError("Base URL is required")
+ return
+ }
+ if existing == nil {
+ for _, s := range a.cfg.Provider.Schemes {
+ if s.Name == name {
+ a.showError(fmt.Sprintf("Scheme name %q already exists", name))
+ return
+ }
+ }
+ }
+ a.hideModal("scheme-form")
+ onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType})
+ }).
+ AddButton("Cancel", func() {
+ a.hideModal("scheme-form")
+ })
+
+ nameField, _ = form.GetFormItemByLabel("Name").(*tview.InputField)
+
+ form.SetBorder(true).SetTitle(title)
+
+ a.showModal("scheme-form", centeredForm(form, 68, 12))
+}
diff --git a/cmd/picoclaw-launcher-tui/ui/users.go b/cmd/picoclaw-launcher-tui/ui/users.go
new file mode 100644
index 000000000..27b7cea7a
--- /dev/null
+++ b/cmd/picoclaw-launcher-tui/ui/users.go
@@ -0,0 +1,161 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package ui
+
+import (
+ "fmt"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+ tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
+)
+
+func (a *App) newUsersPage(schemeName string) tview.Primitive {
+ list := tview.NewList()
+ list.SetBorder(true).SetTitle(fmt.Sprintf(" Users for scheme %q (a:add e:edit d:delete Enter:models) ", schemeName))
+
+ indexInCfg := func(visibleIdx int) int {
+ count := 0
+ for i, u := range a.cfg.Provider.Users {
+ if u.Scheme == schemeName {
+ if count == visibleIdx {
+ return i
+ }
+ count++
+ }
+ }
+ return -1
+ }
+
+ rebuild := func() {
+ sel := list.GetCurrentItem()
+ list.Clear()
+ for _, u := range a.cfg.Provider.Users {
+ if u.Scheme != schemeName {
+ continue
+ }
+ uName := u.Name
+ uType := u.Type
+ list.AddItem(
+ fmt.Sprintf("%s · %s", u.Name, uType),
+ "",
+ 0,
+ func() {
+ a.pages.RemovePage("models")
+ scheme := a.cfg.Provider.SchemeByName(schemeName)
+ if scheme == nil {
+ a.showError(fmt.Sprintf("Scheme %q not found", schemeName))
+ return
+ }
+ a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL))
+ },
+ )
+ }
+ if sel >= 0 && sel < list.GetItemCount() {
+ list.SetCurrentItem(sel)
+ }
+ }
+ rebuild()
+
+ list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ switch event.Rune() {
+ case 'a':
+ a.showUserForm(schemeName, nil, func(u tuicfg.User) {
+ a.cfg.Provider.Users = append(a.cfg.Provider.Users, u)
+ a.save()
+ rebuild()
+ })
+ return nil
+ case 'e':
+ visIdx := list.GetCurrentItem()
+ cfgIdx := indexInCfg(visIdx)
+ if cfgIdx < 0 {
+ return nil
+ }
+ orig := a.cfg.Provider.Users[cfgIdx]
+ a.showUserForm(schemeName, &orig, func(u tuicfg.User) {
+ a.cfg.Provider.Users[cfgIdx] = u
+ a.save()
+ rebuild()
+ })
+ return nil
+ case 'd':
+ visIdx := list.GetCurrentItem()
+ cfgIdx := indexInCfg(visIdx)
+ if cfgIdx < 0 {
+ return nil
+ }
+ uName := a.cfg.Provider.Users[cfgIdx].Name
+ a.confirmDelete(fmt.Sprintf("user %q", uName), func() {
+ users := a.cfg.Provider.Users
+ a.cfg.Provider.Users = append(users[:cfgIdx], users[cfgIdx+1:]...)
+ a.save()
+ rebuild()
+ })
+ return nil
+ }
+ return event
+ })
+
+ footer := hintBar(" Enter: select model a: add e: edit d: delete ESC: back ")
+
+ return tview.NewFlex().
+ SetDirection(tview.FlexRow).
+ AddItem(list, 0, 1, true).
+ AddItem(footer, 1, 0, false)
+}
+
+func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) {
+ name := ""
+ userType := "key"
+ key := ""
+ title := " Add User "
+
+ if existing != nil {
+ name = existing.Name
+ userType = existing.Type
+ key = existing.Key
+ title = " Edit User "
+ }
+
+ typeOptions := []string{"key", "OAuth"}
+ typeIdx := 0
+ for i, t := range typeOptions {
+ if t == userType {
+ typeIdx = i
+ break
+ }
+ }
+
+ form := tview.NewForm()
+ form.
+ AddInputField("Name", name, 40, nil, func(text string) { name = text }).
+ AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }).
+ AddPasswordField("Key", key, 60, '*', func(text string) { key = text }).
+ AddButton("Save", func() {
+ if name == "" {
+ a.showError("Name is required")
+ return
+ }
+ if existing == nil {
+ for _, u := range a.cfg.Provider.Users {
+ if u.Scheme == schemeName && u.Name == name {
+ a.showError(fmt.Sprintf("User name %q already exists for this scheme", name))
+ return
+ }
+ }
+ }
+ a.hideModal("user-form")
+ onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key})
+ }).
+ AddButton("Cancel", func() {
+ a.hideModal("user-form")
+ })
+
+ form.SetBorder(true).SetTitle(title)
+
+ a.showModal("user-form", centeredForm(form, 68, 13))
+}
diff --git a/go.mod b/go.mod
index 39385edca..cfc930d37 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/sipeed/picoclaw
go 1.25.8
require (
+ github.com/BurntSushi/toml v1.6.0
fyne.io/systray v1.12.0
github.com/adhocore/gronx v1.19.6
github.com/anthropics/anthropic-sdk-go v1.26.0
diff --git a/go.sum b/go.sum
index 3e6001480..f24b997d4 100644
--- a/go.sum
+++ b/go.sum
@@ -3,6 +3,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
+github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
From 119cc2e8e156454fa0cc0658ad9b8e3d112e6be1 Mon Sep 17 00:00:00 2001
From: taorye
Date: Fri, 20 Mar 2026 15:39:15 +0800
Subject: [PATCH 30/44] refactor: enhance TUI configuration and user management
with improved UI elements and concurrency
---
cmd/picoclaw-launcher-tui/config/config.go | 3 +
cmd/picoclaw-launcher-tui/ui/app.go | 231 +++++++++++++++++++--
cmd/picoclaw-launcher-tui/ui/home.go | 23 +-
cmd/picoclaw-launcher-tui/ui/models.go | 56 +++--
cmd/picoclaw-launcher-tui/ui/schemes.go | 181 ++++++++++++----
cmd/picoclaw-launcher-tui/ui/users.go | 198 +++++++++++++-----
6 files changed, 545 insertions(+), 147 deletions(-)
diff --git a/cmd/picoclaw-launcher-tui/config/config.go b/cmd/picoclaw-launcher-tui/config/config.go
index 15c81f90a..28bee27cd 100644
--- a/cmd/picoclaw-launcher-tui/config/config.go
+++ b/cmd/picoclaw-launcher-tui/config/config.go
@@ -95,6 +95,9 @@ func Load(path string) (*TUIConfig, error) {
// Save writes cfg to path atomically (safe for flash / SD storage).
func Save(path string, cfg *TUIConfig) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return fmt.Errorf("failed to create config directory: %w", err)
+ }
var buf bytes.Buffer
enc := toml.NewEncoder(&buf)
if err := enc.Encode(cfg); err != nil {
diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go
index c642a1753..b0f1799ea 100644
--- a/cmd/picoclaw-launcher-tui/ui/app.go
+++ b/cmd/picoclaw-launcher-tui/ui/app.go
@@ -6,6 +6,9 @@
package ui
import (
+ "fmt"
+ "sync"
+
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
@@ -13,26 +16,119 @@ import (
// App is the root TUI application.
type App struct {
- tapp *tview.Application
- pages *tview.Pages
- pageStack []string
- cfg *tuicfg.TUIConfig
- configPath string
- homeRefreshFn func()
+ tapp *tview.Application
+ pages *tview.Pages
+ pageStack []string
+ cfg *tuicfg.TUIConfig
+ configPath string
+ pageRefreshFns map[string]func()
+ headerModelTV *tview.TextView
+ modalOpen map[string]bool
+
+ modelCache map[string][]modelEntry
+ modelCacheMu sync.RWMutex
+ refreshMu sync.Mutex
+}
+
+// cacheKey returns the map key for a (scheme, user) pair.
+func cacheKey(schemeName, userName string) string {
+ return fmt.Sprintf("%s/%s", schemeName, userName)
+}
+
+// cachedModels returns a defensive copy of the cached model list for a user (may be nil).
+func (a *App) cachedModels(schemeName, userName string) []modelEntry {
+ a.modelCacheMu.RLock()
+ defer a.modelCacheMu.RUnlock()
+ entries := a.modelCache[cacheKey(schemeName, userName)]
+ return append([]modelEntry(nil), entries...)
+}
+
+// refreshModelCache fetches models for every user in the config concurrently.
+// Serialized by refreshMu so concurrent calls don't race on the cache map.
+// When all fetches complete it calls onDone via QueueUpdateDraw.
+func (a *App) refreshModelCache(onDone func()) {
+ go func() {
+ a.refreshMu.Lock()
+ defer a.refreshMu.Unlock()
+
+ users := a.cfg.Provider.Users
+ schemes := a.cfg.Provider.Schemes
+
+ schemeURL := make(map[string]string, len(schemes))
+ for _, s := range schemes {
+ schemeURL[s.Name] = s.BaseURL
+ }
+
+ var wg sync.WaitGroup
+ for _, u := range users {
+ baseURL, ok := schemeURL[u.Scheme]
+ if !ok || baseURL == "" {
+ continue
+ }
+ if u.Key == "" {
+ a.modelCacheMu.Lock()
+ if a.modelCache == nil {
+ a.modelCache = make(map[string][]modelEntry)
+ }
+ a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
+ a.modelCacheMu.Unlock()
+ continue
+ }
+ wg.Add(1)
+ u := u
+ bURL := baseURL
+ go func() {
+ defer wg.Done()
+ entries, err := fetchModels(bURL, u.Key)
+ a.modelCacheMu.Lock()
+ if a.modelCache == nil {
+ a.modelCache = make(map[string][]modelEntry)
+ }
+ if err != nil || len(entries) == 0 {
+ a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
+ } else {
+ a.modelCache[cacheKey(u.Scheme, u.Name)] = entries
+ }
+ a.modelCacheMu.Unlock()
+ }()
+ }
+ wg.Wait()
+
+ if onDone != nil {
+ a.tapp.QueueUpdateDraw(onDone)
+ }
+ }()
}
// New creates and wires up the TUI application.
func New(cfg *tuicfg.TUIConfig, configPath string) *App {
+ tview.Styles.PrimitiveBackgroundColor = tcell.ColorBlack
+ tview.Styles.ContrastBackgroundColor = tcell.ColorTeal
+ tview.Styles.MoreContrastBackgroundColor = tcell.ColorLime
+ tview.Styles.BorderColor = tcell.ColorDarkCyan
+ tview.Styles.TitleColor = tcell.ColorAqua
+ tview.Styles.GraphicsColor = tcell.ColorDarkCyan
+ tview.Styles.PrimaryTextColor = tcell.ColorWhite
+ tview.Styles.SecondaryTextColor = tcell.ColorSilver
+ tview.Styles.TertiaryTextColor = tcell.ColorAqua
+ tview.Styles.InverseTextColor = tcell.ColorBlack
+ tview.Styles.ContrastSecondaryTextColor = tcell.ColorNavy
+
a := &App{
- tapp: tview.NewApplication(),
- pages: tview.NewPages(),
- pageStack: []string{},
- cfg: cfg,
- configPath: configPath,
+ tapp: tview.NewApplication(),
+ pages: tview.NewPages(),
+ pageStack: []string{},
+ cfg: cfg,
+ configPath: configPath,
+ pageRefreshFns: make(map[string]func()),
+ modalOpen: make(map[string]bool),
}
a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
+ if len(a.modalOpen) > 0 {
+ return nil
+ }
return a.goBack()
}
return event
@@ -53,6 +149,7 @@ func (a *App) buildPages() {
}
func (a *App) navigateTo(name string, page tview.Primitive) {
+ a.pages.RemovePage(name)
a.pages.AddPage(name, page, true, false)
a.pageStack = append(a.pageStack, name)
a.pages.SwitchToPage(name)
@@ -62,26 +159,35 @@ func (a *App) goBack() *tcell.EventKey {
if len(a.pageStack) <= 1 {
return nil
}
+ popped := a.pageStack[len(a.pageStack)-1]
a.pageStack = a.pageStack[:len(a.pageStack)-1]
+ a.pages.RemovePage(popped)
prev := a.pageStack[len(a.pageStack)-1]
- if prev == "home" && a.homeRefreshFn != nil {
- a.homeRefreshFn()
+ if fn, ok := a.pageRefreshFns[prev]; ok {
+ fn()
+ }
+ if prev == "home" && a.headerModelTV != nil {
+ a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ")
}
a.pages.SwitchToPage(prev)
return nil
}
func (a *App) showModal(name string, primitive tview.Primitive) {
+ a.modalOpen[name] = true
a.pages.AddPage(name, primitive, true, true)
}
func (a *App) hideModal(name string) {
+ delete(a.modalOpen, name)
a.pages.HidePage(name)
a.pages.RemovePage(name)
}
func (a *App) save() {
- _ = tuicfg.Save(a.configPath, a.cfg)
+ if err := tuicfg.Save(a.configPath, a.cfg); err != nil {
+ a.showError("save failed: " + err.Error())
+ }
}
func (a *App) showError(msg string) {
@@ -91,6 +197,10 @@ func (a *App) showError(msg string) {
SetDoneFunc(func(_ int, _ string) {
a.hideModal("error")
})
+ modal.SetBackgroundColor(tcell.ColorNavy)
+ modal.SetTextColor(tcell.ColorWhite)
+ modal.SetButtonBackgroundColor(tcell.ColorDarkCyan)
+ modal.SetButtonTextColor(tcell.ColorWhite)
a.showModal("error", modal)
}
@@ -104,20 +214,99 @@ func (a *App) confirmDelete(label string, onConfirm func()) {
onConfirm()
}
})
+ modal.SetBackgroundColor(tcell.ColorNavy)
+ modal.SetTextColor(tcell.ColorWhite)
+ modal.SetButtonBackgroundColor(tcell.ColorDarkCyan)
+ modal.SetButtonTextColor(tcell.ColorWhite)
a.showModal("confirm-delete", modal)
}
-func centeredForm(form *tview.Form, width, height int) tview.Primitive {
- return tview.NewGrid().
- SetColumns(0, width, 0).
- SetRows(0, height, 0).
- AddItem(form, 1, 1, 1, 1, 0, 0, true)
+func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive {
+ return tview.NewFlex().
+ AddItem(tview.NewBox(), 0, 1, false).
+ AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
+ AddItem(tview.NewBox(), 0, 1, false).
+ AddItem(form, height, 1, true).
+ AddItem(tview.NewBox(), 0, 1, false), 0, widthPct, true).
+ AddItem(tview.NewBox(), 0, 1, false)
}
func hintBar(text string) *tview.TextView {
tv := tview.NewTextView().
SetText(text).
- SetTextAlign(tview.AlignCenter)
- tv.SetBackgroundColor(tcell.ColorDarkBlue)
+ SetTextAlign(tview.AlignCenter).
+ SetTextColor(tcell.ColorAqua)
+ tv.SetBackgroundColor(tcell.ColorMidnightBlue)
return tv
}
+
+func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tview.Primitive {
+ var modelTV *tview.TextView
+ if pageID == "home" {
+ if a.headerModelTV == nil {
+ a.headerModelTV = tview.NewTextView()
+ a.headerModelTV.SetTextAlign(tview.AlignRight).
+ SetTextColor(tcell.ColorYellow).
+ SetDynamicColors(true).
+ SetBackgroundColor(tcell.ColorBlack)
+ }
+ modelTV = a.headerModelTV
+ modelTV.SetText(a.cfg.CurrentModelLabel() + " ")
+ } else {
+ modelTV = tview.NewTextView()
+ modelTV.SetBackgroundColor(tcell.ColorBlack)
+ }
+
+ headerLeft := tview.NewTextView().
+ SetText(" ▓▓ PICOCLAW LAUNCHER ▓▓").
+ SetTextColor(tcell.ColorAqua).
+ SetBackgroundColor(tcell.ColorBlack)
+
+ header := tview.NewFlex().
+ AddItem(headerLeft, 0, 1, false).
+ AddItem(modelTV, 0, 1, false)
+
+ sidebar := tview.NewTextView().
+ SetDynamicColors(true).
+ SetWrap(false)
+ sidebar.SetBackgroundColor(tcell.ColorNavy)
+
+ activeColor := "[lime]▶ "
+ inactiveColor := "[gray] "
+
+ sbText := "\n"
+ if pageID == "home" {
+ sbText += activeColor + "HOME[-]\n"
+ } else {
+ sbText += inactiveColor + "HOME[-]\n"
+ }
+ if pageID == "schemes" {
+ sbText += activeColor + "SCHEMES[-]\n"
+ } else {
+ sbText += inactiveColor + "SCHEMES[-]\n"
+ }
+ if pageID == "users" {
+ sbText += activeColor + "USERS[-]\n"
+ } else {
+ sbText += inactiveColor + "USERS[-]\n"
+ }
+ if pageID == "models" {
+ sbText += activeColor + "MODELS[-]\n"
+ } else {
+ sbText += inactiveColor + "MODELS[-]\n"
+ }
+
+ sidebar.SetText(sbText)
+
+ footer := hintBar(hint)
+
+ grid := tview.NewGrid().
+ SetRows(1, 0, 1).
+ SetColumns(16, 0).
+ AddItem(header, 0, 0, 1, 2, 0, 0, false).
+ AddItem(sidebar, 1, 0, 1, 1, 0, 0, false).
+ AddItem(content, 1, 1, 1, 1, 0, 0, true).
+ AddItem(footer, 2, 0, 1, 2, 0, 0, false)
+
+ return grid
+}
diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go
index 6235a2c8e..af25f9b43 100644
--- a/cmd/picoclaw-launcher-tui/ui/home.go
+++ b/cmd/picoclaw-launcher-tui/ui/home.go
@@ -12,32 +12,27 @@ import (
func (a *App) newHomePage() tview.Primitive {
list := tview.NewList()
- list.SetBorder(true).SetTitle(" picoclaw-launcher-tui ")
+ list.SetBorder(true).SetTitle(" Active Configuration ").SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan)
+ list.SetMainTextColor(tcell.ColorWhite)
+ list.SetSecondaryTextColor(tcell.ColorDarkGray)
+ list.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite))
+ list.SetSelectedBackgroundColor(tcell.ColorTeal)
+ list.SetSelectedTextColor(tcell.ColorWhite)
rebuildList := func() {
sel := list.GetCurrentItem()
list.Clear()
list.AddItem("model: "+a.cfg.CurrentModelLabel(), "Enter to configure", 'm', func() {
- a.pages.RemovePage("schemes")
a.navigateTo("schemes", a.newSchemesPage())
})
list.AddItem("Quit", "", 'q', func() { a.tapp.Stop() })
- if sel > 0 && sel < list.GetItemCount() {
+ if sel >= 0 && sel < list.GetItemCount() {
list.SetCurrentItem(sel)
}
}
rebuildList()
- a.homeRefreshFn = rebuildList
+ a.pageRefreshFns["home"] = rebuildList
- list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
- return event
- })
-
- footer := hintBar(" Enter: select q: quit ")
-
- return tview.NewFlex().
- SetDirection(tview.FlexRow).
- AddItem(list, 0, 1, true).
- AddItem(footer, 1, 0, false)
+ return a.buildShell("home", list, " m: configure model q: quit ")
}
diff --git a/cmd/picoclaw-launcher-tui/ui/models.go b/cmd/picoclaw-launcher-tui/ui/models.go
index 5e102d94c..c9747d544 100644
--- a/cmd/picoclaw-launcher-tui/ui/models.go
+++ b/cmd/picoclaw-launcher-tui/ui/models.go
@@ -33,7 +33,9 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
SetBorders(false).
SetSelectable(true, false).
SetFixed(0, 0)
- table.SetBorder(true).SetTitle(fmt.Sprintf(" Models %s / %s ", schemeName, userName))
+ table.SetBorder(true).SetTitle(fmt.Sprintf(" Models · %s / %s ", schemeName, userName))
+ table.SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan)
+ table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite))
var modelIDs []string
@@ -41,19 +43,35 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
SetTextAlign(tview.AlignCenter).
SetDynamicColors(true).
SetText("[yellow]Fetching models…[-]")
-
- footer := hintBar(" Enter: select ESC: back ")
+ status.SetBackgroundColor(tcell.ColorBlack)
flex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(status, 1, 0, false).
- AddItem(table, 0, 1, false).
- AddItem(footer, 1, 0, false)
+ AddItem(table, 0, 1, false)
apiKey := a.resolveKey(schemeName, userName)
go func() {
- entries, err := fetchModels(baseURL, apiKey)
+ var entries []modelEntry
+ var err error
+ if apiKey == "" {
+ err = fmt.Errorf("key is required")
+ } else {
+ entries, err = fetchModels(baseURL, apiKey)
+ }
+
+ a.modelCacheMu.Lock()
+ if a.modelCache == nil {
+ a.modelCache = make(map[string][]modelEntry)
+ }
+ if err == nil && len(entries) > 0 {
+ a.modelCache[cacheKey(schemeName, userName)] = entries
+ } else {
+ a.modelCache[cacheKey(schemeName, userName)] = nil
+ }
+ a.modelCacheMu.Unlock()
+
a.tapp.QueueUpdateDraw(func() {
if err != nil {
status.SetText(fmt.Sprintf("[red]Error: %s[-]", err.Error()))
@@ -68,7 +86,7 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
return
}
- status.SetText(fmt.Sprintf("[green]%d model(s) loaded[-]", len(entries)))
+ status.SetText(fmt.Sprintf("[lime]%d model(s) loaded[-]", len(entries)))
for i, m := range entries {
modelIDs = append(modelIDs, m.ID)
table.SetCell(i, 0,
@@ -80,7 +98,8 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
table.SetCell(i, 1,
tview.NewTableCell(" "+m.ID).
SetAlign(tview.AlignLeft).
- SetExpansion(1),
+ SetExpansion(1).
+ SetTextColor(tcell.ColorWhite),
)
}
a.tapp.SetFocus(table)
@@ -100,7 +119,7 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
a.goBack()
})
- return flex
+ return a.buildShell("models", flex, " Enter: select ESC: back ")
}
func (a *App) resolveKey(schemeName, userName string) string {
@@ -135,9 +154,20 @@ func fetchModels(baseURL, apiKey string) ([]modelEntry, error) {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
- var result modelsAPIResponse
- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
- return nil, fmt.Errorf("decode response: %w", err)
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
}
- return result.Data, nil
+
+ var result modelsAPIResponse
+ if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 {
+ return result.Data, nil
+ }
+
+ var arr []modelEntry
+ if err := json.Unmarshal(body, &arr); err == nil {
+ return arr, nil
+ }
+
+ return nil, fmt.Errorf("decode response: unrecognised shape: %s", strings.TrimSpace(string(body[:min(len(body), 256)])))
}
diff --git a/cmd/picoclaw-launcher-tui/ui/schemes.go b/cmd/picoclaw-launcher-tui/ui/schemes.go
index eec3bda7c..92cae3b42 100644
--- a/cmd/picoclaw-launcher-tui/ui/schemes.go
+++ b/cmd/picoclaw-launcher-tui/ui/schemes.go
@@ -14,74 +14,159 @@ import (
)
func (a *App) newSchemesPage() tview.Primitive {
- list := tview.NewList()
- list.SetBorder(true).SetTitle(" Provider Schemes (a:add e:edit d:delete Enter:users) ")
+ table := tview.NewTable().
+ SetBorders(false).
+ SetSelectable(true, false)
+ table.SetBorder(true).SetTitle(" Provider Schemes ")
+ table.SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan)
+ table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite))
+
+ rowToIdx := func(row int) int { return row / 2 }
+
+ selectedSchemeName := func() string {
+ row, _ := table.GetSelection()
+ idx := rowToIdx(row)
+ schemes := a.cfg.Provider.Schemes
+ if idx >= 0 && idx < len(schemes) {
+ return schemes[idx].Name
+ }
+ return ""
+ }
rebuild := func() {
- sel := list.GetCurrentItem()
- list.Clear()
- for _, s := range a.cfg.Provider.Schemes {
- name := s.Name
- list.AddItem(
- fmt.Sprintf("%s · %s [%s]", s.Name, s.BaseURL, s.Type),
- "",
- 0,
- func() {
- a.pages.RemovePage("users")
- a.navigateTo("users", a.newUsersPage(name))
- },
+ selName := selectedSchemeName()
+ table.Clear()
+ schemes := a.cfg.Provider.Schemes
+ for i, s := range schemes {
+ nameRow := i * 2
+ detailRow := nameRow + 1
+
+ table.SetCell(nameRow, 0,
+ tview.NewTableCell(" "+s.Name).
+ SetTextColor(tcell.ColorWhite).
+ SetExpansion(1).
+ SetSelectable(true),
+ )
+
+ users := a.cfg.Provider.UsersForScheme(s.Name)
+ n := len(users)
+ m := 0
+ for _, u := range users {
+ if models := a.cachedModels(s.Name, u.Name); len(models) > 0 {
+ m++
+ }
+ }
+ table.SetCell(detailRow, 0,
+ tview.NewTableCell(fmt.Sprintf(" (%d/%d)%s", m, n, s.BaseURL)).
+ SetTextColor(tcell.ColorDarkGray).
+ SetExpansion(1).
+ SetSelectable(false),
+ )
+ table.SetCell(detailRow, 1,
+ tview.NewTableCell(s.Type+" ").
+ SetTextColor(tcell.ColorDarkGray).
+ SetAlign(tview.AlignRight).
+ SetSelectable(false),
)
}
- if sel >= 0 && sel < list.GetItemCount() {
- list.SetCurrentItem(sel)
+ if selName != "" {
+ for i, s := range schemes {
+ if s.Name == selName {
+ table.Select(i*2, 0)
+ return
+ }
+ }
+ }
+ if table.GetRowCount() > 0 {
+ table.Select(0, 0)
}
}
rebuild()
- list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ a.refreshModelCache(rebuild)
+ a.pageRefreshFns["schemes"] = func() { a.refreshModelCache(rebuild) }
+
+ table.SetSelectedFunc(func(row, _ int) {
+ idx := rowToIdx(row)
+ schemes := a.cfg.Provider.Schemes
+ if idx < 0 || idx >= len(schemes) {
+ return
+ }
+ name := schemes[idx].Name
+ a.navigateTo("users", a.newUsersPage(name))
+ })
+
+ table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ row, _ := table.GetSelection()
+ idx := rowToIdx(row)
+ schemes := a.cfg.Provider.Schemes
switch event.Rune() {
case 'a':
a.showSchemeForm(nil, func(s tuicfg.Scheme) {
a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s)
a.save()
- rebuild()
+ a.refreshModelCache(rebuild)
})
return nil
case 'e':
- idx := list.GetCurrentItem()
- if idx < 0 || idx >= len(a.cfg.Provider.Schemes) {
+ if idx < 0 || idx >= len(schemes) {
return nil
}
- orig := a.cfg.Provider.Schemes[idx]
+ origName := schemes[idx].Name
+ orig := schemes[idx]
a.showSchemeForm(&orig, func(s tuicfg.Scheme) {
- a.cfg.Provider.Schemes[idx] = s
+ current := a.cfg.Provider.Schemes
+ for i, sc := range current {
+ if sc.Name == origName {
+ a.cfg.Provider.Schemes[i] = s
+ break
+ }
+ }
a.save()
- rebuild()
+ a.refreshModelCache(func() {
+ rebuild()
+ for i, sc := range a.cfg.Provider.Schemes {
+ if sc.Name == s.Name {
+ table.Select(i*2, 0)
+ break
+ }
+ }
+ })
})
return nil
case 'd':
- idx := list.GetCurrentItem()
- if idx < 0 || idx >= len(a.cfg.Provider.Schemes) {
+ if idx < 0 || idx >= len(schemes) {
return nil
}
- name := a.cfg.Provider.Schemes[idx].Name
+ name := schemes[idx].Name
a.confirmDelete(fmt.Sprintf("scheme %q", name), func() {
- schemes := a.cfg.Provider.Schemes
- a.cfg.Provider.Schemes = append(schemes[:idx], schemes[idx+1:]...)
+ current := a.cfg.Provider.Schemes
+ newSchemes := make([]tuicfg.Scheme, 0, len(current))
+ for _, sc := range current {
+ if sc.Name != name {
+ newSchemes = append(newSchemes, sc)
+ }
+ }
+ a.cfg.Provider.Schemes = newSchemes
+
+ existing := a.cfg.Provider.Users
+ filtered := make([]tuicfg.User, 0, len(existing))
+ for _, u := range existing {
+ if u.Scheme != name {
+ filtered = append(filtered, u)
+ }
+ }
+ a.cfg.Provider.Users = filtered
+
a.save()
- rebuild()
+ a.refreshModelCache(rebuild)
})
return nil
}
return event
})
- footer := hintBar(" Enter: users a: add e: edit d: delete ESC: back ")
-
- return tview.NewFlex().
- SetDirection(tview.FlexRow).
- AddItem(list, 0, 1, true).
- AddItem(footer, 1, 0, false)
+ return a.buildShell("schemes", table, " a: add e: edit d: delete Enter: open ESC: back ")
}
func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) {
@@ -108,14 +193,11 @@ func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)
form := tview.NewForm()
- var nameField *tview.InputField
-
form.
- AddInputField("Name", name, 40, nil, func(text string) { name = text }).
- AddInputField("Base URL", baseURL, 60, nil, func(text string) { baseURL = text }).
+ AddInputField("Name", name, 32, nil, func(text string) { name = text }).
+ AddInputField("Base URL", baseURL, 32, nil, func(text string) { baseURL = text }).
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }).
AddButton("Save", func() {
- _ = nameField
if name == "" {
a.showError("Name is required")
return
@@ -139,9 +221,20 @@ func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)
a.hideModal("scheme-form")
})
- nameField, _ = form.GetFormItemByLabel("Name").(*tview.InputField)
+ form.SetBorder(true).SetTitle(title).SetTitleColor(tcell.ColorLime)
+ form.SetBorderColor(tcell.ColorDarkCyan)
+ form.SetFieldBackgroundColor(tcell.ColorBlack)
+ form.SetFieldTextColor(tcell.ColorWhite)
+ form.SetLabelColor(tcell.ColorAqua)
+ form.SetButtonBackgroundColor(tcell.ColorDarkCyan)
+ form.SetButtonTextColor(tcell.ColorWhite)
+ form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ if event.Key() == tcell.KeyEscape {
+ a.hideModal("scheme-form")
+ return nil
+ }
+ return event
+ })
- form.SetBorder(true).SetTitle(title)
-
- a.showModal("scheme-form", centeredForm(form, 68, 12))
+ a.showModal("scheme-form", centeredForm(form, 6, 12))
}
diff --git a/cmd/picoclaw-launcher-tui/ui/users.go b/cmd/picoclaw-launcher-tui/ui/users.go
index 27b7cea7a..f561938d5 100644
--- a/cmd/picoclaw-launcher-tui/ui/users.go
+++ b/cmd/picoclaw-launcher-tui/ui/users.go
@@ -14,98 +14,173 @@ import (
)
func (a *App) newUsersPage(schemeName string) tview.Primitive {
- list := tview.NewList()
- list.SetBorder(true).SetTitle(fmt.Sprintf(" Users for scheme %q (a:add e:edit d:delete Enter:models) ", schemeName))
+ table := tview.NewTable().
+ SetBorders(false).
+ SetSelectable(true, false)
+ table.SetBorder(true).SetTitle(fmt.Sprintf(" Users · %s ", schemeName))
+ table.SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan)
+ table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite))
- indexInCfg := func(visibleIdx int) int {
- count := 0
- for i, u := range a.cfg.Provider.Users {
+ visibleUsers := func() []tuicfg.User {
+ var out []tuicfg.User
+ for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName {
- if count == visibleIdx {
- return i
- }
- count++
+ out = append(out, u)
+ }
+ }
+ return out
+ }
+
+ findUserGlobalIdx := func(userName string) int {
+ for i, u := range a.cfg.Provider.Users {
+ if u.Scheme == schemeName && u.Name == userName {
+ return i
}
}
return -1
}
+ rowToVisIdx := func(row int) int { return row / 2 }
+
+ selectedUserName := func() string {
+ row, _ := table.GetSelection()
+ users := visibleUsers()
+ visIdx := rowToVisIdx(row)
+ if visIdx >= 0 && visIdx < len(users) {
+ return users[visIdx].Name
+ }
+ return ""
+ }
+
rebuild := func() {
- sel := list.GetCurrentItem()
- list.Clear()
- for _, u := range a.cfg.Provider.Users {
- if u.Scheme != schemeName {
- continue
+ selName := selectedUserName()
+ table.Clear()
+ users := visibleUsers()
+ for i, u := range users {
+ nameRow := i * 2
+ detailRow := nameRow + 1
+
+ table.SetCell(nameRow, 0,
+ tview.NewTableCell(" "+u.Name).
+ SetTextColor(tcell.ColorWhite).
+ SetExpansion(1).
+ SetSelectable(true),
+ )
+ table.SetCell(nameRow, 1,
+ tview.NewTableCell("").
+ SetSelectable(false),
+ )
+
+ models := a.cachedModels(schemeName, u.Name)
+ var detailText string
+ if len(models) > 0 {
+ detailText = fmt.Sprintf(" %d models", len(models))
+ } else {
+ detailText = " [red]Inactive[-]"
}
- uName := u.Name
- uType := u.Type
- list.AddItem(
- fmt.Sprintf("%s · %s", u.Name, uType),
- "",
- 0,
- func() {
- a.pages.RemovePage("models")
- scheme := a.cfg.Provider.SchemeByName(schemeName)
- if scheme == nil {
- a.showError(fmt.Sprintf("Scheme %q not found", schemeName))
- return
- }
- a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL))
- },
+ table.SetCell(detailRow, 0,
+ tview.NewTableCell(detailText).
+ SetTextColor(tcell.ColorDarkGray).
+ SetExpansion(1).
+ SetSelectable(false),
+ )
+ table.SetCell(detailRow, 1,
+ tview.NewTableCell(u.Type+" ").
+ SetTextColor(tcell.ColorDarkGray).
+ SetAlign(tview.AlignRight).
+ SetSelectable(false),
)
}
- if sel >= 0 && sel < list.GetItemCount() {
- list.SetCurrentItem(sel)
+ if selName != "" {
+ for i, u := range users {
+ if u.Name == selName {
+ table.Select(i*2, 0)
+ return
+ }
+ }
+ }
+ if table.GetRowCount() > 0 {
+ table.Select(0, 0)
}
}
rebuild()
- list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ a.refreshModelCache(rebuild)
+ a.pageRefreshFns["users"] = func() { a.refreshModelCache(rebuild) }
+
+ table.SetSelectedFunc(func(row, _ int) {
+ visIdx := rowToVisIdx(row)
+ users := visibleUsers()
+ if visIdx < 0 || visIdx >= len(users) {
+ return
+ }
+ uName := users[visIdx].Name
+ scheme := a.cfg.Provider.SchemeByName(schemeName)
+ if scheme == nil {
+ a.showError(fmt.Sprintf("Scheme %q not found", schemeName))
+ return
+ }
+ a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL))
+ })
+
+ table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ row, _ := table.GetSelection()
+ visIdx := rowToVisIdx(row)
+ users := visibleUsers()
switch event.Rune() {
case 'a':
a.showUserForm(schemeName, nil, func(u tuicfg.User) {
a.cfg.Provider.Users = append(a.cfg.Provider.Users, u)
a.save()
- rebuild()
+ a.refreshModelCache(rebuild)
})
return nil
case 'e':
- visIdx := list.GetCurrentItem()
- cfgIdx := indexInCfg(visIdx)
- if cfgIdx < 0 {
+ if visIdx < 0 || visIdx >= len(users) {
return nil
}
- orig := a.cfg.Provider.Users[cfgIdx]
+ origName := users[visIdx].Name
+ orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)]
a.showUserForm(schemeName, &orig, func(u tuicfg.User) {
+ cfgIdx := findUserGlobalIdx(origName)
+ if cfgIdx < 0 {
+ a.showError(fmt.Sprintf("User %q no longer exists", origName))
+ return
+ }
a.cfg.Provider.Users[cfgIdx] = u
a.save()
- rebuild()
+ a.refreshModelCache(func() {
+ rebuild()
+ for i, usr := range visibleUsers() {
+ if usr.Name == u.Name {
+ table.Select(i*2, 0)
+ break
+ }
+ }
+ })
})
return nil
case 'd':
- visIdx := list.GetCurrentItem()
- cfgIdx := indexInCfg(visIdx)
- if cfgIdx < 0 {
+ if visIdx < 0 || visIdx >= len(users) {
return nil
}
- uName := a.cfg.Provider.Users[cfgIdx].Name
+ uName := users[visIdx].Name
a.confirmDelete(fmt.Sprintf("user %q", uName), func() {
- users := a.cfg.Provider.Users
- a.cfg.Provider.Users = append(users[:cfgIdx], users[cfgIdx+1:]...)
+ cfgIdx := findUserGlobalIdx(uName)
+ if cfgIdx < 0 {
+ return
+ }
+ all := a.cfg.Provider.Users
+ a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...)
a.save()
- rebuild()
+ a.refreshModelCache(rebuild)
})
return nil
}
return event
})
- footer := hintBar(" Enter: select model a: add e: edit d: delete ESC: back ")
-
- return tview.NewFlex().
- SetDirection(tview.FlexRow).
- AddItem(list, 0, 1, true).
- AddItem(footer, 1, 0, false)
+ return a.buildShell("users", table, " a: add e: edit d: delete Enter: models ESC: back ")
}
func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) {
@@ -132,9 +207,9 @@ func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func
form := tview.NewForm()
form.
- AddInputField("Name", name, 40, nil, func(text string) { name = text }).
+ AddInputField("Name", name, 32, nil, func(text string) { name = text }).
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }).
- AddPasswordField("Key", key, 60, '*', func(text string) { key = text }).
+ AddPasswordField("Key", key, 32, '*', func(text string) { key = text }).
AddButton("Save", func() {
if name == "" {
a.showError("Name is required")
@@ -155,7 +230,20 @@ func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func
a.hideModal("user-form")
})
- form.SetBorder(true).SetTitle(title)
+ form.SetBorder(true).SetTitle(title).SetTitleColor(tcell.ColorLime)
+ form.SetBorderColor(tcell.ColorDarkCyan)
+ form.SetFieldBackgroundColor(tcell.ColorBlack)
+ form.SetFieldTextColor(tcell.ColorWhite)
+ form.SetLabelColor(tcell.ColorAqua)
+ form.SetButtonBackgroundColor(tcell.ColorDarkCyan)
+ form.SetButtonTextColor(tcell.ColorWhite)
+ form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ if event.Key() == tcell.KeyEscape {
+ a.hideModal("user-form")
+ return nil
+ }
+ return event
+ })
- a.showModal("user-form", centeredForm(form, 68, 13))
+ a.showModal("user-form", centeredForm(form, 6, 13))
}
From 74a145c29114820a72bb94f56de6f2873334dc1c Mon Sep 17 00:00:00 2001
From: taorye
Date: Fri, 20 Mar 2026 17:04:57 +0800
Subject: [PATCH 31/44] style: apply cyberpunk theme to TUI components for
enhanced visual appeal
---
cmd/picoclaw-launcher-tui/ui/app.go | 117 +++++++++++++-----------
cmd/picoclaw-launcher-tui/ui/home.go | 18 ++--
cmd/picoclaw-launcher-tui/ui/models.go | 26 +++---
cmd/picoclaw-launcher-tui/ui/schemes.go | 45 +++++----
cmd/picoclaw-launcher-tui/ui/users.go | 47 +++++-----
5 files changed, 129 insertions(+), 124 deletions(-)
diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go
index b0f1799ea..53d1cf8cd 100644
--- a/cmd/picoclaw-launcher-tui/ui/app.go
+++ b/cmd/picoclaw-launcher-tui/ui/app.go
@@ -102,17 +102,23 @@ func (a *App) refreshModelCache(onDone func()) {
// New creates and wires up the TUI application.
func New(cfg *tuicfg.TUIConfig, configPath string) *App {
- tview.Styles.PrimitiveBackgroundColor = tcell.ColorBlack
- tview.Styles.ContrastBackgroundColor = tcell.ColorTeal
- tview.Styles.MoreContrastBackgroundColor = tcell.ColorLime
- tview.Styles.BorderColor = tcell.ColorDarkCyan
- tview.Styles.TitleColor = tcell.ColorAqua
- tview.Styles.GraphicsColor = tcell.ColorDarkCyan
- tview.Styles.PrimaryTextColor = tcell.ColorWhite
- tview.Styles.SecondaryTextColor = tcell.ColorSilver
- tview.Styles.TertiaryTextColor = tcell.ColorAqua
- tview.Styles.InverseTextColor = tcell.ColorBlack
- tview.Styles.ContrastSecondaryTextColor = tcell.ColorNavy
+ // Cyberpunk Theme Colors
+ // Dark background
+ tview.Styles.PrimitiveBackgroundColor = tcell.NewHexColor(0x050510) // Deep Void
+ tview.Styles.ContrastBackgroundColor = tcell.NewHexColor(0x1a1a2e) // Dark Indigo
+ tview.Styles.MoreContrastBackgroundColor = tcell.NewHexColor(0x2a2a40)
+
+ // Borders and Titles
+ tview.Styles.BorderColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
+ tview.Styles.TitleColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
+ tview.Styles.GraphicsColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
+
+ // Text
+ tview.Styles.PrimaryTextColor = tcell.NewHexColor(0xe0e0e0) // Off-white
+ tview.Styles.SecondaryTextColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
+ tview.Styles.TertiaryTextColor = tcell.NewHexColor(0x39ff14) // Neon Lime
+ tview.Styles.InverseTextColor = tcell.NewHexColor(0x000000) // Black
+ tview.Styles.ContrastSecondaryTextColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
a := &App{
tapp: tview.NewApplication(),
@@ -127,7 +133,7 @@ func New(cfg *tuicfg.TUIConfig, configPath string) *App {
a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
if len(a.modalOpen) > 0 {
- return nil
+ return event
}
return a.goBack()
}
@@ -192,21 +198,22 @@ func (a *App) save() {
func (a *App) showError(msg string) {
modal := tview.NewModal().
- SetText("Error: " + msg).
+ SetText(" [red::b]ERROR[-::-]\n\n" + msg).
AddButtons([]string{"OK"}).
SetDoneFunc(func(_ int, _ string) {
a.hideModal("error")
})
- modal.SetBackgroundColor(tcell.ColorNavy)
- modal.SetTextColor(tcell.ColorWhite)
- modal.SetButtonBackgroundColor(tcell.ColorDarkCyan)
- modal.SetButtonTextColor(tcell.ColorWhite)
+ // Cyberpunk Modal Style
+ modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
+ modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
+ modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red
+ modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
a.showModal("error", modal)
}
func (a *App) confirmDelete(label string, onConfirm func()) {
modal := tview.NewModal().
- SetText("Delete " + label + "?\nThis cannot be undone.").
+ SetText(" [red::b]DELETE WARNING[-::-]\n\nDelete " + label + "?\n[gray]This action cannot be undone.[-]").
AddButtons([]string{"Delete", "Cancel"}).
SetDoneFunc(func(_ int, buttonLabel string) {
a.hideModal("confirm-delete")
@@ -214,10 +221,11 @@ func (a *App) confirmDelete(label string, onConfirm func()) {
onConfirm()
}
})
- modal.SetBackgroundColor(tcell.ColorNavy)
- modal.SetTextColor(tcell.ColorWhite)
- modal.SetButtonBackgroundColor(tcell.ColorDarkCyan)
- modal.SetButtonTextColor(tcell.ColorWhite)
+ // Cyberpunk Modal Style
+ modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
+ modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
+ modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red for danger
+ modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
a.showModal("confirm-delete", modal)
}
@@ -234,9 +242,10 @@ func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive {
func hintBar(text string) *tview.TextView {
tv := tview.NewTextView().
SetText(text).
+ SetDynamicColors(true).
SetTextAlign(tview.AlignCenter).
- SetTextColor(tcell.ColorAqua)
- tv.SetBackgroundColor(tcell.ColorMidnightBlue)
+ SetTextColor(tcell.NewHexColor(0x00f0ff)) // Neon Cyan
+ tv.SetBackgroundColor(tcell.NewHexColor(0x2a2a40)) // Darker Indigo
return tv
}
@@ -246,21 +255,21 @@ func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tv
if a.headerModelTV == nil {
a.headerModelTV = tview.NewTextView()
a.headerModelTV.SetTextAlign(tview.AlignRight).
- SetTextColor(tcell.ColorYellow).
+ SetTextColor(tcell.NewHexColor(0x39ff14)). // Neon Lime
SetDynamicColors(true).
- SetBackgroundColor(tcell.ColorBlack)
+ SetBackgroundColor(tcell.NewHexColor(0x050510))
}
modelTV = a.headerModelTV
- modelTV.SetText(a.cfg.CurrentModelLabel() + " ")
+ modelTV.SetText("MODEL: " + a.cfg.CurrentModelLabel() + " ")
} else {
modelTV = tview.NewTextView()
- modelTV.SetBackgroundColor(tcell.ColorBlack)
+ modelTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
}
headerLeft := tview.NewTextView().
- SetText(" ▓▓ PICOCLAW LAUNCHER ▓▓").
- SetTextColor(tcell.ColorAqua).
- SetBackgroundColor(tcell.ColorBlack)
+ SetText(" [#ff00ff::b]///[#00f0ff] PICOCLAW LAUNCHER [#ff00ff]///").
+ SetDynamicColors(true).
+ SetBackgroundColor(tcell.NewHexColor(0x050510))
header := tview.NewFlex().
AddItem(headerLeft, 0, 1, false).
@@ -269,44 +278,42 @@ func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tv
sidebar := tview.NewTextView().
SetDynamicColors(true).
SetWrap(false)
- sidebar.SetBackgroundColor(tcell.ColorNavy)
+ sidebar.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
- activeColor := "[lime]▶ "
- inactiveColor := "[gray] "
+ // Cyberpunk Sidebar Styling
+ activePrefix := "[#39ff14::b]>> " // Neon Lime arrow
+ activeSuffix := "[-]"
+ inactivePrefix := "[#808080] "
+ inactiveSuffix := "[-]"
- sbText := "\n"
- if pageID == "home" {
- sbText += activeColor + "HOME[-]\n"
- } else {
- sbText += inactiveColor + "HOME[-]\n"
- }
- if pageID == "schemes" {
- sbText += activeColor + "SCHEMES[-]\n"
- } else {
- sbText += inactiveColor + "SCHEMES[-]\n"
- }
- if pageID == "users" {
- sbText += activeColor + "USERS[-]\n"
- } else {
- sbText += inactiveColor + "USERS[-]\n"
- }
- if pageID == "models" {
- sbText += activeColor + "MODELS[-]\n"
- } else {
- sbText += inactiveColor + "MODELS[-]\n"
+ sbText := "\n\n" // Top padding
+
+ menuItem := func(id, label string) string {
+ if pageID == id {
+ return activePrefix + label + activeSuffix + "\n\n"
+ }
+ return inactivePrefix + label + inactiveSuffix + "\n\n"
}
+ sbText += menuItem("home", "HOME")
+ sbText += menuItem("schemes", "SCHEMES")
+ sbText += menuItem("users", "USERS")
+ sbText += menuItem("models", "MODELS")
+
sidebar.SetText(sbText)
footer := hintBar(hint)
grid := tview.NewGrid().
SetRows(1, 0, 1).
- SetColumns(16, 0).
+ SetColumns(20, 0). // Slightly wider sidebar
AddItem(header, 0, 0, 1, 2, 0, 0, false).
AddItem(sidebar, 1, 0, 1, 1, 0, 0, false).
AddItem(content, 1, 1, 1, 1, 0, 0, true).
AddItem(footer, 2, 0, 1, 2, 0, 0, false)
+ // Add a border around the content area if possible, or ensure content has its own border
+ // grid.SetBorders(false) // Grid borders usually look bad, handled by components
+
return grid
}
diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go
index af25f9b43..4e952d534 100644
--- a/cmd/picoclaw-launcher-tui/ui/home.go
+++ b/cmd/picoclaw-launcher-tui/ui/home.go
@@ -12,20 +12,20 @@ import (
func (a *App) newHomePage() tview.Primitive {
list := tview.NewList()
- list.SetBorder(true).SetTitle(" Active Configuration ").SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan)
- list.SetMainTextColor(tcell.ColorWhite)
- list.SetSecondaryTextColor(tcell.ColorDarkGray)
- list.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite))
- list.SetSelectedBackgroundColor(tcell.ColorTeal)
- list.SetSelectedTextColor(tcell.ColorWhite)
+ list.SetBorder(true).SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION ").SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff))
+ list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
+ list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
+ list.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)))
+ list.SetHighlightFullLine(true)
+ list.SetBackgroundColor(tcell.NewHexColor(0x050510))
rebuildList := func() {
sel := list.GetCurrentItem()
list.Clear()
- list.AddItem("model: "+a.cfg.CurrentModelLabel(), "Enter to configure", 'm', func() {
+ list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() {
a.navigateTo("schemes", a.newSchemesPage())
})
- list.AddItem("Quit", "", 'q', func() { a.tapp.Stop() })
+ list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() })
if sel >= 0 && sel < list.GetItemCount() {
list.SetCurrentItem(sel)
}
@@ -34,5 +34,5 @@ func (a *App) newHomePage() tview.Primitive {
a.pageRefreshFns["home"] = rebuildList
- return a.buildShell("home", list, " m: configure model q: quit ")
+ return a.buildShell("home", list, " [#00f0ff]m:[-] configure model [#ff2a2a]q:[-] quit ")
}
diff --git a/cmd/picoclaw-launcher-tui/ui/models.go b/cmd/picoclaw-launcher-tui/ui/models.go
index c9747d544..46daaeb3e 100644
--- a/cmd/picoclaw-launcher-tui/ui/models.go
+++ b/cmd/picoclaw-launcher-tui/ui/models.go
@@ -33,17 +33,17 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
SetBorders(false).
SetSelectable(true, false).
SetFixed(0, 0)
- table.SetBorder(true).SetTitle(fmt.Sprintf(" Models · %s / %s ", schemeName, userName))
- table.SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan)
- table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite))
+ table.SetBorder(true).SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)).SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff))
+ table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)))
+ table.SetBackgroundColor(tcell.NewHexColor(0x050510))
var modelIDs []string
status := tview.NewTextView().
SetTextAlign(tview.AlignCenter).
SetDynamicColors(true).
- SetText("[yellow]Fetching models…[-]")
- status.SetBackgroundColor(tcell.ColorBlack)
+ SetText("[#ffff00]FETCHING MODELS...[-]")
+ status.SetBackgroundColor(tcell.NewHexColor(0x050510))
flex := tview.NewFlex().
SetDirection(tview.FlexRow).
@@ -74,32 +74,32 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
a.tapp.QueueUpdateDraw(func() {
if err != nil {
- status.SetText(fmt.Sprintf("[red]Error: %s[-]", err.Error()))
- table.SetCell(0, 0, tview.NewTableCell("(failed to load models)"))
+ status.SetText(fmt.Sprintf("[#ff2a2a]ERROR: %s[-]", err.Error()))
+ table.SetCell(0, 0, tview.NewTableCell(" (failed to load models)"))
a.tapp.SetFocus(table)
return
}
if len(entries) == 0 {
- status.SetText("[yellow]No models returned[-]")
- table.SetCell(0, 0, tview.NewTableCell("(no models available)"))
+ status.SetText("[#ff2a2a]NO MODELS RETURNED[-]")
+ table.SetCell(0, 0, tview.NewTableCell(" (no models available)"))
a.tapp.SetFocus(table)
return
}
- status.SetText(fmt.Sprintf("[lime]%d model(s) loaded[-]", len(entries)))
+ status.SetText(fmt.Sprintf("[#39ff14]%d MODEL(S) LOADED[-]", len(entries)))
for i, m := range entries {
modelIDs = append(modelIDs, m.ID)
table.SetCell(i, 0,
tview.NewTableCell(fmt.Sprintf("%3d", i+1)).
SetAlign(tview.AlignRight).
- SetTextColor(tcell.ColorGray).
+ SetTextColor(tcell.NewHexColor(0x808080)).
SetSelectable(false),
)
table.SetCell(i, 1,
tview.NewTableCell(" "+m.ID).
SetAlign(tview.AlignLeft).
SetExpansion(1).
- SetTextColor(tcell.ColorWhite),
+ SetTextColor(tcell.NewHexColor(0xe0e0e0)),
)
}
a.tapp.SetFocus(table)
@@ -119,7 +119,7 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
a.goBack()
})
- return a.buildShell("models", flex, " Enter: select ESC: back ")
+ return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ")
}
func (a *App) resolveKey(schemeName, userName string) string {
diff --git a/cmd/picoclaw-launcher-tui/ui/schemes.go b/cmd/picoclaw-launcher-tui/ui/schemes.go
index 92cae3b42..70375eccc 100644
--- a/cmd/picoclaw-launcher-tui/ui/schemes.go
+++ b/cmd/picoclaw-launcher-tui/ui/schemes.go
@@ -17,9 +17,9 @@ func (a *App) newSchemesPage() tview.Primitive {
table := tview.NewTable().
SetBorders(false).
SetSelectable(true, false)
- table.SetBorder(true).SetTitle(" Provider Schemes ")
- table.SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan)
- table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite))
+ table.SetBorder(true).SetTitle(" [#00f0ff::b] PROVIDER SCHEMES ").SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff))
+ table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)))
+ table.SetBackgroundColor(tcell.NewHexColor(0x050510))
rowToIdx := func(row int) int { return row / 2 }
@@ -43,7 +43,7 @@ func (a *App) newSchemesPage() tview.Primitive {
table.SetCell(nameRow, 0,
tview.NewTableCell(" "+s.Name).
- SetTextColor(tcell.ColorWhite).
+ SetTextColor(tcell.NewHexColor(0xe0e0e0)).
SetExpansion(1).
SetSelectable(true),
)
@@ -57,14 +57,13 @@ func (a *App) newSchemesPage() tview.Primitive {
}
}
table.SetCell(detailRow, 0,
- tview.NewTableCell(fmt.Sprintf(" (%d/%d)%s", m, n, s.BaseURL)).
- SetTextColor(tcell.ColorDarkGray).
+ tview.NewTableCell(fmt.Sprintf(" [#808080](%d/%d) %s", m, n, s.BaseURL)).
+ SetTextColor(tcell.NewHexColor(0x808080)).
SetExpansion(1).
SetSelectable(false),
)
table.SetCell(detailRow, 1,
- tview.NewTableCell(s.Type+" ").
- SetTextColor(tcell.ColorDarkGray).
+ tview.NewTableCell("[#00f0ff]"+s.Type+" ").
SetAlign(tview.AlignRight).
SetSelectable(false),
)
@@ -166,20 +165,20 @@ func (a *App) newSchemesPage() tview.Primitive {
return event
})
- return a.buildShell("schemes", table, " a: add e: edit d: delete Enter: open ESC: back ")
+ return a.buildShell("schemes", table, " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ")
}
func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) {
name := ""
baseURL := ""
schemeType := "openai-compatible"
- title := " Add Scheme "
+ title := " ADD SCHEME "
if existing != nil {
name = existing.Name
baseURL = existing.BaseURL
schemeType = existing.Type
- title = " Edit Scheme "
+ title = " EDIT SCHEME "
}
typeOptions := []string{"openai-compatible", "anthropic"}
@@ -194,10 +193,10 @@ func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)
form := tview.NewForm()
form.
- AddInputField("Name", name, 32, nil, func(text string) { name = text }).
- AddInputField("Base URL", baseURL, 32, nil, func(text string) { baseURL = text }).
+ AddInputField("Name", name, 20, nil, func(text string) { name = text }).
+ AddInputField("Base URL", baseURL, 28, nil, func(text string) { baseURL = text }).
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }).
- AddButton("Save", func() {
+ AddButton("SAVE", func() {
if name == "" {
a.showError("Name is required")
return
@@ -217,17 +216,17 @@ func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)
a.hideModal("scheme-form")
onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType})
}).
- AddButton("Cancel", func() {
+ AddButton("CANCEL", func() {
a.hideModal("scheme-form")
})
- form.SetBorder(true).SetTitle(title).SetTitleColor(tcell.ColorLime)
- form.SetBorderColor(tcell.ColorDarkCyan)
- form.SetFieldBackgroundColor(tcell.ColorBlack)
- form.SetFieldTextColor(tcell.ColorWhite)
- form.SetLabelColor(tcell.ColorAqua)
- form.SetButtonBackgroundColor(tcell.ColorDarkCyan)
- form.SetButtonTextColor(tcell.ColorWhite)
+ form.SetBorder(true).SetTitle(" [::b]" + title + " ").SetTitleColor(tcell.NewHexColor(0x39ff14)).SetBorderColor(tcell.NewHexColor(0x00f0ff))
+ form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
+ form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
+ form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
+ form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
+ form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
+ form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
a.hideModal("scheme-form")
@@ -236,5 +235,5 @@ func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)
return event
})
- a.showModal("scheme-form", centeredForm(form, 6, 12))
+ a.showModal("scheme-form", centeredForm(form, 4, 12))
}
diff --git a/cmd/picoclaw-launcher-tui/ui/users.go b/cmd/picoclaw-launcher-tui/ui/users.go
index f561938d5..4a877d3c7 100644
--- a/cmd/picoclaw-launcher-tui/ui/users.go
+++ b/cmd/picoclaw-launcher-tui/ui/users.go
@@ -17,9 +17,9 @@ func (a *App) newUsersPage(schemeName string) tview.Primitive {
table := tview.NewTable().
SetBorders(false).
SetSelectable(true, false)
- table.SetBorder(true).SetTitle(fmt.Sprintf(" Users · %s ", schemeName))
- table.SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan)
- table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite))
+ table.SetBorder(true).SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)).SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff))
+ table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)))
+ table.SetBackgroundColor(tcell.NewHexColor(0x050510))
visibleUsers := func() []tuicfg.User {
var out []tuicfg.User
@@ -62,7 +62,7 @@ func (a *App) newUsersPage(schemeName string) tview.Primitive {
table.SetCell(nameRow, 0,
tview.NewTableCell(" "+u.Name).
- SetTextColor(tcell.ColorWhite).
+ SetTextColor(tcell.NewHexColor(0xe0e0e0)).
SetExpansion(1).
SetSelectable(true),
)
@@ -74,19 +74,18 @@ func (a *App) newUsersPage(schemeName string) tview.Primitive {
models := a.cachedModels(schemeName, u.Name)
var detailText string
if len(models) > 0 {
- detailText = fmt.Sprintf(" %d models", len(models))
+ detailText = fmt.Sprintf(" [#39ff14]%d models available[-]", len(models))
} else {
- detailText = " [red]Inactive[-]"
+ detailText = " [#ff2a2a]Inactive / No Access[-]"
}
table.SetCell(detailRow, 0,
tview.NewTableCell(detailText).
- SetTextColor(tcell.ColorDarkGray).
+ SetTextColor(tcell.NewHexColor(0x808080)).
SetExpansion(1).
SetSelectable(false),
)
table.SetCell(detailRow, 1,
- tview.NewTableCell(u.Type+" ").
- SetTextColor(tcell.ColorDarkGray).
+ tview.NewTableCell("[#00f0ff]"+u.Type+" ").
SetAlign(tview.AlignRight).
SetSelectable(false),
)
@@ -180,20 +179,20 @@ func (a *App) newUsersPage(schemeName string) tview.Primitive {
return event
})
- return a.buildShell("users", table, " a: add e: edit d: delete Enter: models ESC: back ")
+ return a.buildShell("users", table, " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ")
}
func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) {
name := ""
userType := "key"
key := ""
- title := " Add User "
+ title := " ADD USER "
if existing != nil {
name = existing.Name
userType = existing.Type
key = existing.Key
- title = " Edit User "
+ title = " EDIT USER "
}
typeOptions := []string{"key", "OAuth"}
@@ -207,10 +206,10 @@ func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func
form := tview.NewForm()
form.
- AddInputField("Name", name, 32, nil, func(text string) { name = text }).
+ AddInputField("Name", name, 20, nil, func(text string) { name = text }).
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }).
- AddPasswordField("Key", key, 32, '*', func(text string) { key = text }).
- AddButton("Save", func() {
+ AddPasswordField("Key", key, 28, '*', func(text string) { key = text }).
+ AddButton("SAVE", func() {
if name == "" {
a.showError("Name is required")
return
@@ -226,17 +225,17 @@ func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func
a.hideModal("user-form")
onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key})
}).
- AddButton("Cancel", func() {
+ AddButton("CANCEL", func() {
a.hideModal("user-form")
})
- form.SetBorder(true).SetTitle(title).SetTitleColor(tcell.ColorLime)
- form.SetBorderColor(tcell.ColorDarkCyan)
- form.SetFieldBackgroundColor(tcell.ColorBlack)
- form.SetFieldTextColor(tcell.ColorWhite)
- form.SetLabelColor(tcell.ColorAqua)
- form.SetButtonBackgroundColor(tcell.ColorDarkCyan)
- form.SetButtonTextColor(tcell.ColorWhite)
+ form.SetBorder(true).SetTitle(" [::b]" + title + " ").SetTitleColor(tcell.NewHexColor(0x39ff14)).SetBorderColor(tcell.NewHexColor(0x00f0ff))
+ form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
+ form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
+ form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
+ form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
+ form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
+ form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
a.hideModal("user-form")
@@ -245,5 +244,5 @@ func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func
return event
})
- a.showModal("user-form", centeredForm(form, 6, 13))
+ a.showModal("user-form", centeredForm(form, 4, 13))
}
From 545b7afe41ecff8df64ed9464f48e95d93df1d4b Mon Sep 17 00:00:00 2001
From: taorye
Date: Fri, 20 Mar 2026 17:37:04 +0800
Subject: [PATCH 32/44] feat: add model selection synchronization to main
config in TUI
---
cmd/picoclaw-launcher-tui/config/config.go | 73 ++++++++++++++++++++++
cmd/picoclaw-launcher-tui/main.go | 4 ++
cmd/picoclaw-launcher-tui/ui/app.go | 4 ++
cmd/picoclaw-launcher-tui/ui/models.go | 18 ++++++
4 files changed, 99 insertions(+)
diff --git a/cmd/picoclaw-launcher-tui/config/config.go b/cmd/picoclaw-launcher-tui/config/config.go
index 28bee27cd..64d479285 100644
--- a/cmd/picoclaw-launcher-tui/config/config.go
+++ b/cmd/picoclaw-launcher-tui/config/config.go
@@ -8,6 +8,7 @@ package config
import (
"bytes"
+ "encoding/json"
"fmt"
"os"
"path/filepath"
@@ -149,6 +150,78 @@ func (p *Provider) UsersForScheme(schemeName string) []User {
return out
}
+// SyncSelectedModelToMainConfig syncs the currently selected model to ~/.picoclaw/config.json
+// Adds/replaces a "tui-prefer" model entry and sets it as the default model.
+// Preserves all other existing fields in the config file unchanged.
+func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) error {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ home = "."
+ }
+ mainConfigPath := filepath.Join(home, ".picoclaw", "config.json")
+
+ var cfg map[string]interface{}
+ if data, err := os.ReadFile(mainConfigPath); err == nil {
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ cfg = make(map[string]interface{})
+ }
+ } else {
+ cfg = make(map[string]interface{})
+ }
+
+ if _, ok := cfg["agents"]; !ok {
+ cfg["agents"] = make(map[string]interface{})
+ }
+ agents, ok := cfg["agents"].(map[string]interface{})
+ if ok {
+ if _, ok := agents["defaults"]; !ok {
+ agents["defaults"] = make(map[string]interface{})
+ }
+ defaults, ok := agents["defaults"].(map[string]interface{})
+ if ok {
+ defaults["model"] = "tui-prefer"
+ }
+ }
+
+ tuiModel := map[string]interface{}{
+ "model_name": "tui-prefer",
+ "model": modelID,
+ "api_key": user.Key,
+ "api_base": scheme.BaseURL,
+ }
+
+ modelList := []interface{}{}
+ if ml, ok := cfg["model_list"].([]interface{}); ok {
+ modelList = ml
+ }
+
+ found := false
+ for i, m := range modelList {
+ if entry, ok := m.(map[string]interface{}); ok {
+ if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" {
+ modelList[i] = tuiModel
+ found = true
+ break
+ }
+ }
+ }
+ if !found {
+ modelList = append(modelList, tuiModel)
+ }
+ cfg["model_list"] = modelList
+
+ data, err := json.MarshalIndent(cfg, "", " ")
+ if err != nil {
+ return err
+ }
+
+ if err := os.MkdirAll(filepath.Dir(mainConfigPath), 0o700); err != nil {
+ return err
+ }
+
+ return os.WriteFile(mainConfigPath, data, 0o600)
+}
+
func (cfg *TUIConfig) CurrentModelLabel() string {
cur := cfg.Provider.Current
if cur.Model == "" {
diff --git a/cmd/picoclaw-launcher-tui/main.go b/cmd/picoclaw-launcher-tui/main.go
index 3d7e62b08..057206ab1 100644
--- a/cmd/picoclaw-launcher-tui/main.go
+++ b/cmd/picoclaw-launcher-tui/main.go
@@ -26,6 +26,10 @@ func main() {
}
app := ui.New(cfg, configPath)
+ // Bind model selection hook to sync to main config
+ app.OnModelSelected = func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) {
+ _ = tuicfg.SyncSelectedModelToMainConfig(scheme, user, modelID)
+ }
if err := app.Run(); err != nil {
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
os.Exit(1)
diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go
index 53d1cf8cd..4978935d9 100644
--- a/cmd/picoclaw-launcher-tui/ui/app.go
+++ b/cmd/picoclaw-launcher-tui/ui/app.go
@@ -25,6 +25,10 @@ type App struct {
headerModelTV *tview.TextView
modalOpen map[string]bool
+ // OnModelSelected is called when a model is selected in the UI.
+ // Can be nil to disable.
+ OnModelSelected func(scheme tuicfg.Scheme, user tuicfg.User, modelID string)
+
modelCache map[string][]modelEntry
modelCacheMu sync.RWMutex
refreshMu sync.Mutex
diff --git a/cmd/picoclaw-launcher-tui/ui/models.go b/cmd/picoclaw-launcher-tui/ui/models.go
index 46daaeb3e..1f9484b26 100644
--- a/cmd/picoclaw-launcher-tui/ui/models.go
+++ b/cmd/picoclaw-launcher-tui/ui/models.go
@@ -116,6 +116,24 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
Model: modelIDs[row],
}
a.save()
+
+ // Trigger model selected callback if set
+ if a.OnModelSelected != nil && a.cfg.Model.Type == "provider" {
+ scheme := a.cfg.Provider.SchemeByName(schemeName)
+ if scheme == nil {
+ a.goBack()
+ return
+ }
+ var user tuicfg.User
+ for _, u := range a.cfg.Provider.Users {
+ if u.Scheme == schemeName && u.Name == userName {
+ user = u
+ break
+ }
+ }
+ a.OnModelSelected(*scheme, user, modelIDs[row])
+ }
+
a.goBack()
})
From 7b4d5d4513bc8669710df808995dcf92a5832c19 Mon Sep 17 00:00:00 2001
From: taorye
Date: Fri, 20 Mar 2026 17:58:33 +0800
Subject: [PATCH 33/44] feat: add channels management page and integrate into
home menu
---
cmd/picoclaw-launcher-tui/ui/app.go | 1 +
cmd/picoclaw-launcher-tui/ui/channels.go | 194 +++++++++++++++++++++++
cmd/picoclaw-launcher-tui/ui/home.go | 3 +
3 files changed, 198 insertions(+)
create mode 100644 cmd/picoclaw-launcher-tui/ui/channels.go
diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go
index 4978935d9..b410581f9 100644
--- a/cmd/picoclaw-launcher-tui/ui/app.go
+++ b/cmd/picoclaw-launcher-tui/ui/app.go
@@ -303,6 +303,7 @@ func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tv
sbText += menuItem("schemes", "SCHEMES")
sbText += menuItem("users", "USERS")
sbText += menuItem("models", "MODELS")
+ sbText += menuItem("channels", "CHANNELS")
sidebar.SetText(sbText)
diff --git a/cmd/picoclaw-launcher-tui/ui/channels.go b/cmd/picoclaw-launcher-tui/ui/channels.go
new file mode 100644
index 000000000..4ba87b617
--- /dev/null
+++ b/cmd/picoclaw-launcher-tui/ui/channels.go
@@ -0,0 +1,194 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package ui
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strconv"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+func (a *App) newChannelsPage() tview.Primitive {
+ list := tview.NewList()
+ list.SetBorder(true).SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS ").SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff))
+ list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
+ list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
+ list.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)))
+ list.SetHighlightFullLine(true)
+ list.SetBackgroundColor(tcell.NewHexColor(0x050510))
+
+ rebuild := func() {
+ sel := list.GetCurrentItem()
+ list.Clear()
+
+ home, err := os.UserHomeDir()
+ if err != nil {
+ home = "."
+ }
+ configPath := filepath.Join(home, ".picoclaw", "config.json")
+
+ var cfg map[string]interface{}
+ if data, err := os.ReadFile(configPath); err == nil {
+ _ = json.Unmarshal(data, &cfg)
+ }
+
+ if chRaw, ok := cfg["channels"].(map[string]interface{}); ok {
+ for name, ch := range chRaw {
+ chMap, ok := ch.(map[string]interface{})
+ enabled := "disabled"
+ if ok {
+ if e, ok := chMap["enabled"].(bool); ok && e {
+ enabled = "enabled"
+ }
+ }
+ list.AddItem(name, fmt.Sprintf("Status: %s", enabled), 0, func() {
+ a.showChannelEditForm(configPath, name, chMap)
+ })
+ }
+ }
+
+ if sel >= 0 && sel < list.GetItemCount() {
+ list.SetCurrentItem(sel)
+ }
+ }
+ rebuild()
+
+ a.pageRefreshFns["channels"] = rebuild
+
+ list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ if event.Key() == tcell.KeyEscape {
+ return a.goBack()
+ }
+ return event
+ })
+
+ return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ")
+}
+
+func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]interface{}) {
+ form := tview.NewForm()
+ form.SetBorder(true).SetTitle(" [::b]EDIT CHANNEL ").SetTitleColor(tcell.NewHexColor(0x39ff14)).SetBorderColor(tcell.NewHexColor(0x00f0ff))
+ form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
+ form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
+ form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
+ form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
+ form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
+ form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
+
+ fields := make(map[string]*tview.InputField)
+ var nameField *tview.InputField
+
+ if channelName == "" {
+ nameField = tview.NewInputField().
+ SetLabel("Channel Name").
+ SetText("").
+ SetFieldWidth(28)
+ form.AddFormItem(nameField)
+ }
+
+ for k, v := range existing {
+ if reflect.ValueOf(v).Kind() == reflect.Map || reflect.ValueOf(v).Kind() == reflect.Slice {
+ continue
+ }
+ valStr := fmt.Sprintf("%v", v)
+ field := tview.NewInputField().
+ SetLabel(k).
+ SetText(valStr).
+ SetFieldWidth(28)
+ form.AddFormItem(field)
+ fields[k] = field
+ }
+
+ form.AddButton("SAVE", func() {
+ var cfg map[string]interface{}
+ if data, err := os.ReadFile(configPath); err == nil {
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ cfg = make(map[string]interface{})
+ }
+ } else {
+ cfg = make(map[string]interface{})
+ }
+
+ if _, ok := cfg["channels"]; !ok {
+ cfg["channels"] = make(map[string]interface{})
+ }
+ channels, ok := cfg["channels"].(map[string]interface{})
+ if !ok {
+ channels = make(map[string]interface{})
+ cfg["channels"] = channels
+ }
+
+ finalName := channelName
+ if channelName == "" {
+ if nameField == nil || nameField.GetText() == "" {
+ a.showError("Channel name is required")
+ return
+ }
+ finalName = nameField.GetText()
+ }
+
+ updated := make(map[string]interface{})
+ if existing != nil {
+ for k, v := range existing {
+ updated[k] = v
+ }
+ }
+ for k, field := range fields {
+ val := field.GetText()
+ if val == "true" {
+ updated[k] = true
+ } else if val == "false" {
+ updated[k] = false
+ } else if num, err := strconv.Atoi(val); err == nil {
+ updated[k] = num
+ } else {
+ updated[k] = val
+ }
+ }
+
+ if channelName != "" && finalName != channelName {
+ delete(channels, channelName)
+ }
+ channels[finalName] = updated
+
+ data, err := json.MarshalIndent(cfg, "", " ")
+ if err != nil {
+ a.showError(fmt.Sprintf("Failed to save config: %v", err))
+ return
+ }
+ if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
+ a.showError(fmt.Sprintf("Failed to create config directory: %v", err))
+ return
+ }
+ if err := os.WriteFile(configPath, data, 0o600); err != nil {
+ a.showError(fmt.Sprintf("Failed to write config: %v", err))
+ return
+ }
+
+ a.hideModal("channel-edit")
+ a.goBack()
+ })
+
+ form.AddButton("CANCEL", func() {
+ a.hideModal("channel-edit")
+ })
+
+ form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ if event.Key() == tcell.KeyEscape {
+ a.hideModal("channel-edit")
+ return nil
+ }
+ return event
+ })
+
+ a.showModal("channel-edit", centeredForm(form, 4, 20))
+}
diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go
index 4e952d534..49524acf1 100644
--- a/cmd/picoclaw-launcher-tui/ui/home.go
+++ b/cmd/picoclaw-launcher-tui/ui/home.go
@@ -25,6 +25,9 @@ func (a *App) newHomePage() tview.Primitive {
list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() {
a.navigateTo("schemes", a.newSchemesPage())
})
+ list.AddItem("CHANNELS: Configure communication channels", "Manage Telegram/Discord/WeChat channels", 'n', func() {
+ a.navigateTo("channels", a.newChannelsPage())
+ })
list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() })
if sel >= 0 && sel < list.GetItemCount() {
list.SetCurrentItem(sel)
From 02da117199934a15bd5f152c4a063a997a055700 Mon Sep 17 00:00:00 2001
From: taorye
Date: Fri, 20 Mar 2026 19:07:06 +0800
Subject: [PATCH 34/44] feat: add gateway management page to TUI and integrate
into home menu
---
cmd/picoclaw-launcher-tui/ui/app.go | 1 +
cmd/picoclaw-launcher-tui/ui/gateway.go | 251 ++++++++++++++++++++++++
cmd/picoclaw-launcher-tui/ui/home.go | 3 +
3 files changed, 255 insertions(+)
create mode 100644 cmd/picoclaw-launcher-tui/ui/gateway.go
diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go
index b410581f9..512277129 100644
--- a/cmd/picoclaw-launcher-tui/ui/app.go
+++ b/cmd/picoclaw-launcher-tui/ui/app.go
@@ -304,6 +304,7 @@ func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tv
sbText += menuItem("users", "USERS")
sbText += menuItem("models", "MODELS")
sbText += menuItem("channels", "CHANNELS")
+ sbText += menuItem("gateway", "GATEWAY")
sidebar.SetText(sbText)
diff --git a/cmd/picoclaw-launcher-tui/ui/gateway.go b/cmd/picoclaw-launcher-tui/ui/gateway.go
new file mode 100644
index 000000000..d71f7b488
--- /dev/null
+++ b/cmd/picoclaw-launcher-tui/ui/gateway.go
@@ -0,0 +1,251 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package ui
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+const pidFileName = "gateway.pid"
+
+type gatewayStatus struct {
+ running bool
+ pid int
+}
+
+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 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)
+ return gatewayStatus{running: false}
+ }
+ return gatewayStatus{
+ running: true,
+ pid: pid,
+ }
+}
+
+func startGateway() error {
+ status := getGatewayStatus()
+ if status.running {
+ 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)
+ }
+
+ err := cmd.Start()
+ if err != nil {
+ return err
+ }
+
+ time.Sleep(1 * time.Second)
+
+ if runtime.GOOS == "windows" {
+ cmd := exec.Command("wmic", "process", "where", "name='picoclaw.exe' and commandline like '%gateway%'", "get", "processid")
+ output, err := cmd.Output()
+ if err != nil {
+ return fmt.Errorf("failed to get gateway PID: %w", err)
+ }
+ lines := strings.Split(string(output), "\n")
+ for _, line := range lines[1:] {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ pid, err := strconv.Atoi(line)
+ if err == nil {
+ os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0o600)
+ break
+ }
+ }
+ }
+
+ status = getGatewayStatus()
+ if !status.running {
+ return fmt.Errorf("failed to start gateway")
+ }
+ return nil
+}
+
+func stopGateway() error {
+ status := getGatewayStatus()
+ if !status.running {
+ return fmt.Errorf("gateway is not running")
+ }
+
+ var err 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()
+ }
+ if err != nil {
+ return err
+ }
+
+ // 多次尝试确认进程已停止
+ for i := 0; i < 5; i++ {
+ if !isProcessRunning(status.pid) {
+ break
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+
+ os.Remove(getPidPath())
+ return nil
+}
+
+func (a *App) newGatewayPage() tview.Primitive {
+ flex := tview.NewFlex().SetDirection(tview.FlexRow)
+ flex.SetBorder(true).SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT ").SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff))
+ flex.SetBackgroundColor(tcell.NewHexColor(0x050510))
+
+ statusTV := tview.NewTextView().
+ SetDynamicColors(true).
+ SetTextAlign(tview.AlignCenter).
+ SetText("Checking status...")
+ statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
+
+ var updateStatus func()
+
+ // 使用List作为按钮,保证显示和交互正常
+ buttons := tview.NewList()
+ buttons.SetBackgroundColor(tcell.NewHexColor(0x050510))
+ buttons.SetMainTextColor(tcell.ColorWhite)
+ buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff))
+ buttons.SetSelectedTextColor(tcell.ColorBlack)
+
+ buttons.AddItem(" [lime]START[white] ", "", 0, func() {
+ if !getGatewayStatus().running {
+ err := startGateway()
+ if err != nil {
+ a.showError(err.Error())
+ }
+ updateStatus()
+ }
+ })
+ buttons.AddItem(" [red]STOP[white] ", "", 0, func() {
+ if getGatewayStatus().running {
+ err := stopGateway()
+ if err != nil {
+ a.showError(err.Error())
+ }
+ updateStatus()
+ }
+ })
+
+ buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn)
+ buttonFlex.
+ AddItem(tview.NewBox(), 0, 1, false).
+ AddItem(buttons, 20, 1, true).
+ AddItem(tview.NewBox(), 0, 1, false)
+
+ flex.
+ AddItem(tview.NewBox(), 0, 1, false).
+ AddItem(statusTV, 3, 1, false).
+ AddItem(tview.NewBox(), 0, 1, false).
+ AddItem(buttonFlex, 4, 1, true).
+ AddItem(tview.NewBox(), 0, 1, false)
+
+ updateStatus = func() {
+ status := getGatewayStatus()
+ if status.running {
+ statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d", status.pid))
+ buttons.SetItemText(0, " [gray]START[white] ", "")
+ buttons.SetItemText(1, " [red]STOP[white] ", "")
+ } else {
+ statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A")
+ buttons.SetItemText(0, " [lime]START[white] ", "")
+ buttons.SetItemText(1, " [gray]STOP[white] ", "")
+ }
+ }
+
+ updateStatus()
+
+ done := make(chan struct{})
+ go func() {
+ ticker := time.NewTicker(2 * time.Second)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ a.tapp.QueueUpdateDraw(updateStatus)
+ case <-done:
+ return
+ }
+ }
+ }()
+
+ originalInputCapture := flex.GetInputCapture()
+ flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ if event.Key() == tcell.KeyEscape {
+ close(done)
+ return a.goBack()
+ }
+ if originalInputCapture != nil {
+ return originalInputCapture(event)
+ }
+ return event
+ })
+
+ a.pageRefreshFns["gateway"] = updateStatus
+
+ return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ")
+}
diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go
index 49524acf1..e3563f2bc 100644
--- a/cmd/picoclaw-launcher-tui/ui/home.go
+++ b/cmd/picoclaw-launcher-tui/ui/home.go
@@ -28,6 +28,9 @@ func (a *App) newHomePage() tview.Primitive {
list.AddItem("CHANNELS: Configure communication channels", "Manage Telegram/Discord/WeChat channels", 'n', func() {
a.navigateTo("channels", a.newChannelsPage())
})
+ list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() {
+ a.navigateTo("gateway", a.newGatewayPage())
+ })
list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() })
if sel >= 0 && sel < list.GetItemCount() {
list.SetCurrentItem(sel)
From 8c44597c3dda8bda660b9a21e05f464813935a4b Mon Sep 17 00:00:00 2001
From: taorye
Date: Fri, 20 Mar 2026 19:16:36 +0800
Subject: [PATCH 35/44] feat: add chat functionality to home page for
interactive AI sessions
---
cmd/picoclaw-launcher-tui/ui/home.go | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go
index e3563f2bc..6d906eccd 100644
--- a/cmd/picoclaw-launcher-tui/ui/home.go
+++ b/cmd/picoclaw-launcher-tui/ui/home.go
@@ -6,6 +6,9 @@
package ui
import (
+ "os"
+ "os/exec"
+
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
@@ -31,6 +34,15 @@ func (a *App) newHomePage() tview.Primitive {
list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() {
a.navigateTo("gateway", a.newGatewayPage())
})
+ list.AddItem("CHAT: Start AI agent chat", "Launch interactive chat session", 'c', func() {
+ a.tapp.Suspend(func() {
+ cmd := exec.Command("picoclaw", "agent")
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ _ = cmd.Run()
+ })
+ })
list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() })
if sel >= 0 && sel < list.GetItemCount() {
list.SetCurrentItem(sel)
@@ -40,5 +52,5 @@ func (a *App) newHomePage() tview.Primitive {
a.pageRefreshFns["home"] = rebuildList
- return a.buildShell("home", list, " [#00f0ff]m:[-] configure model [#ff2a2a]q:[-] quit ")
+ return a.buildShell("home", list, " [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ")
}
From ed47d5f7c301d2b76c98e8a11f0ac7f659debf3a Mon Sep 17 00:00:00 2001
From: taorye
Date: Fri, 20 Mar 2026 19:20:58 +0800
Subject: [PATCH 36/44] feat: add onboarding command execution for non-existent
config directory
---
cmd/picoclaw-launcher-tui/main.go | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/cmd/picoclaw-launcher-tui/main.go b/cmd/picoclaw-launcher-tui/main.go
index 057206ab1..3cb7110c1 100644
--- a/cmd/picoclaw-launcher-tui/main.go
+++ b/cmd/picoclaw-launcher-tui/main.go
@@ -8,6 +8,8 @@ package main
import (
"fmt"
"os"
+ "os/exec"
+ "path/filepath"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui"
@@ -19,6 +21,15 @@ func main() {
configPath = os.Args[1]
}
+ configDir := filepath.Dir(configPath)
+ if _, err := os.Stat(configDir); os.IsNotExist(err) {
+ cmd := exec.Command("picoclaw", "onboard")
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ _ = cmd.Run()
+ }
+
cfg, err := tuicfg.Load(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
From 955d6e70f19daed49245a12c21a1516692d180ce Mon Sep 17 00:00:00 2001
From: taorye
Date: Fri, 20 Mar 2026 19:41:59 +0800
Subject: [PATCH 37/44] refactor: update interface types to use 'any' and
improve code formatting
---
cmd/picoclaw-launcher-tui/config/config.go | 27 ++++++++--------
cmd/picoclaw-launcher-tui/ui/app.go | 2 +-
cmd/picoclaw-launcher-tui/ui/channels.go | 36 +++++++++++++---------
cmd/picoclaw-launcher-tui/ui/gateway.go | 14 +++++++--
cmd/picoclaw-launcher-tui/ui/home.go | 26 ++++++++++++----
cmd/picoclaw-launcher-tui/ui/models.go | 15 +++++++--
cmd/picoclaw-launcher-tui/ui/schemes.go | 21 ++++++++++---
cmd/picoclaw-launcher-tui/ui/users.go | 21 ++++++++++---
8 files changed, 115 insertions(+), 47 deletions(-)
diff --git a/cmd/picoclaw-launcher-tui/config/config.go b/cmd/picoclaw-launcher-tui/config/config.go
index 64d479285..227b9fa3d 100644
--- a/cmd/picoclaw-launcher-tui/config/config.go
+++ b/cmd/picoclaw-launcher-tui/config/config.go
@@ -14,6 +14,7 @@ import (
"path/filepath"
"github.com/BurntSushi/toml"
+
"github.com/sipeed/picoclaw/pkg/fileutil"
)
@@ -160,44 +161,44 @@ func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) err
}
mainConfigPath := filepath.Join(home, ".picoclaw", "config.json")
- var cfg map[string]interface{}
- if data, err := os.ReadFile(mainConfigPath); err == nil {
- if err := json.Unmarshal(data, &cfg); err != nil {
- cfg = make(map[string]interface{})
+ var cfg map[string]any
+ if data, readErr := os.ReadFile(mainConfigPath); readErr == nil {
+ if unmarshalErr := json.Unmarshal(data, &cfg); unmarshalErr != nil {
+ cfg = make(map[string]any)
}
} else {
- cfg = make(map[string]interface{})
+ cfg = make(map[string]any)
}
if _, ok := cfg["agents"]; !ok {
- cfg["agents"] = make(map[string]interface{})
+ cfg["agents"] = make(map[string]any)
}
- agents, ok := cfg["agents"].(map[string]interface{})
+ agents, ok := cfg["agents"].(map[string]any)
if ok {
if _, ok := agents["defaults"]; !ok {
- agents["defaults"] = make(map[string]interface{})
+ agents["defaults"] = make(map[string]any)
}
- defaults, ok := agents["defaults"].(map[string]interface{})
+ defaults, ok := agents["defaults"].(map[string]any)
if ok {
defaults["model"] = "tui-prefer"
}
}
- tuiModel := map[string]interface{}{
+ tuiModel := map[string]any{
"model_name": "tui-prefer",
"model": modelID,
"api_key": user.Key,
"api_base": scheme.BaseURL,
}
- modelList := []interface{}{}
- if ml, ok := cfg["model_list"].([]interface{}); ok {
+ modelList := []any{}
+ if ml, ok := cfg["model_list"].([]any); ok {
modelList = ml
}
found := false
for i, m := range modelList {
- if entry, ok := m.(map[string]interface{}); ok {
+ if entry, ok := m.(map[string]any); ok {
if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" {
modelList[i] = tuiModel
found = true
diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go
index 512277129..a65693b01 100644
--- a/cmd/picoclaw-launcher-tui/ui/app.go
+++ b/cmd/picoclaw-launcher-tui/ui/app.go
@@ -11,6 +11,7 @@ import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
+
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
@@ -79,7 +80,6 @@ func (a *App) refreshModelCache(onDone func()) {
continue
}
wg.Add(1)
- u := u
bURL := baseURL
go func() {
defer wg.Done()
diff --git a/cmd/picoclaw-launcher-tui/ui/channels.go b/cmd/picoclaw-launcher-tui/ui/channels.go
index 4ba87b617..c976f1fcd 100644
--- a/cmd/picoclaw-launcher-tui/ui/channels.go
+++ b/cmd/picoclaw-launcher-tui/ui/channels.go
@@ -19,10 +19,15 @@ import (
func (a *App) newChannelsPage() tview.Primitive {
list := tview.NewList()
- list.SetBorder(true).SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS ").SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff))
+ list.SetBorder(true).
+ SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS ").
+ SetTitleColor(tcell.NewHexColor(0x00f0ff)).
+ SetBorderColor(tcell.NewHexColor(0x00f0ff))
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
- list.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)))
+ list.SetSelectedStyle(
+ tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)),
+ )
list.SetHighlightFullLine(true)
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
@@ -36,14 +41,14 @@ func (a *App) newChannelsPage() tview.Primitive {
}
configPath := filepath.Join(home, ".picoclaw", "config.json")
- var cfg map[string]interface{}
+ var cfg map[string]any
if data, err := os.ReadFile(configPath); err == nil {
_ = json.Unmarshal(data, &cfg)
}
- if chRaw, ok := cfg["channels"].(map[string]interface{}); ok {
+ if chRaw, ok := cfg["channels"].(map[string]any); ok {
for name, ch := range chRaw {
- chMap, ok := ch.(map[string]interface{})
+ chMap, ok := ch.(map[string]any)
enabled := "disabled"
if ok {
if e, ok := chMap["enabled"].(bool); ok && e {
@@ -74,9 +79,12 @@ func (a *App) newChannelsPage() tview.Primitive {
return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ")
}
-func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]interface{}) {
+func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]any) {
form := tview.NewForm()
- form.SetBorder(true).SetTitle(" [::b]EDIT CHANNEL ").SetTitleColor(tcell.NewHexColor(0x39ff14)).SetBorderColor(tcell.NewHexColor(0x00f0ff))
+ form.SetBorder(true).
+ SetTitle(" [::b]EDIT CHANNEL ").
+ SetTitleColor(tcell.NewHexColor(0x39ff14)).
+ SetBorderColor(tcell.NewHexColor(0x00f0ff))
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
@@ -109,21 +117,21 @@ func (a *App) showChannelEditForm(configPath, channelName string, existing map[s
}
form.AddButton("SAVE", func() {
- var cfg map[string]interface{}
+ var cfg map[string]any
if data, err := os.ReadFile(configPath); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
- cfg = make(map[string]interface{})
+ cfg = make(map[string]any)
}
} else {
- cfg = make(map[string]interface{})
+ cfg = make(map[string]any)
}
if _, ok := cfg["channels"]; !ok {
- cfg["channels"] = make(map[string]interface{})
+ cfg["channels"] = make(map[string]any)
}
- channels, ok := cfg["channels"].(map[string]interface{})
+ channels, ok := cfg["channels"].(map[string]any)
if !ok {
- channels = make(map[string]interface{})
+ channels = make(map[string]any)
cfg["channels"] = channels
}
@@ -136,7 +144,7 @@ func (a *App) showChannelEditForm(configPath, channelName string, existing map[s
finalName = nameField.GetText()
}
- updated := make(map[string]interface{})
+ updated := make(map[string]any)
if existing != nil {
for k, v := range existing {
updated[k] = v
diff --git a/cmd/picoclaw-launcher-tui/ui/gateway.go b/cmd/picoclaw-launcher-tui/ui/gateway.go
index d71f7b488..1138c12db 100644
--- a/cmd/picoclaw-launcher-tui/ui/gateway.go
+++ b/cmd/picoclaw-launcher-tui/ui/gateway.go
@@ -98,7 +98,14 @@ func startGateway() error {
time.Sleep(1 * time.Second)
if runtime.GOOS == "windows" {
- cmd := exec.Command("wmic", "process", "where", "name='picoclaw.exe' and commandline like '%gateway%'", "get", "processid")
+ cmd := exec.Command(
+ "wmic",
+ "process",
+ "where",
+ "name='picoclaw.exe' and commandline like '%gateway%'",
+ "get",
+ "processid",
+ )
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to get gateway PID: %w", err)
@@ -154,7 +161,10 @@ func stopGateway() error {
func (a *App) newGatewayPage() tview.Primitive {
flex := tview.NewFlex().SetDirection(tview.FlexRow)
- flex.SetBorder(true).SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT ").SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff))
+ flex.SetBorder(true).
+ SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT ").
+ SetTitleColor(tcell.NewHexColor(0x00f0ff)).
+ SetBorderColor(tcell.NewHexColor(0x00f0ff))
flex.SetBackgroundColor(tcell.NewHexColor(0x050510))
statusTV := tview.NewTextView().
diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go
index 6d906eccd..74a7769cf 100644
--- a/cmd/picoclaw-launcher-tui/ui/home.go
+++ b/cmd/picoclaw-launcher-tui/ui/home.go
@@ -15,10 +15,15 @@ import (
func (a *App) newHomePage() tview.Primitive {
list := tview.NewList()
- list.SetBorder(true).SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION ").SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff))
+ list.SetBorder(true).
+ SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION ").
+ SetTitleColor(tcell.NewHexColor(0x00f0ff)).
+ SetBorderColor(tcell.NewHexColor(0x00f0ff))
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
- list.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)))
+ list.SetSelectedStyle(
+ tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)),
+ )
list.SetHighlightFullLine(true)
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
@@ -28,9 +33,14 @@ func (a *App) newHomePage() tview.Primitive {
list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() {
a.navigateTo("schemes", a.newSchemesPage())
})
- list.AddItem("CHANNELS: Configure communication channels", "Manage Telegram/Discord/WeChat channels", 'n', func() {
- a.navigateTo("channels", a.newChannelsPage())
- })
+ list.AddItem(
+ "CHANNELS: Configure communication channels",
+ "Manage Telegram/Discord/WeChat channels",
+ 'n',
+ func() {
+ a.navigateTo("channels", a.newChannelsPage())
+ },
+ )
list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() {
a.navigateTo("gateway", a.newGatewayPage())
})
@@ -52,5 +62,9 @@ func (a *App) newHomePage() tview.Primitive {
a.pageRefreshFns["home"] = rebuildList
- return a.buildShell("home", list, " [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ")
+ return a.buildShell(
+ "home",
+ list,
+ " [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ",
+ )
}
diff --git a/cmd/picoclaw-launcher-tui/ui/models.go b/cmd/picoclaw-launcher-tui/ui/models.go
index 1f9484b26..20e5f0182 100644
--- a/cmd/picoclaw-launcher-tui/ui/models.go
+++ b/cmd/picoclaw-launcher-tui/ui/models.go
@@ -15,6 +15,7 @@ import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
+
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
@@ -33,8 +34,13 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
SetBorders(false).
SetSelectable(true, false).
SetFixed(0, 0)
- table.SetBorder(true).SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)).SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff))
- table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)))
+ table.SetBorder(true).
+ SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)).
+ SetTitleColor(tcell.NewHexColor(0x00f0ff)).
+ SetBorderColor(tcell.NewHexColor(0x00f0ff))
+ table.SetSelectedStyle(
+ tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
+ )
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
var modelIDs []string
@@ -187,5 +193,8 @@ func fetchModels(baseURL, apiKey string) ([]modelEntry, error) {
return arr, nil
}
- return nil, fmt.Errorf("decode response: unrecognised shape: %s", strings.TrimSpace(string(body[:min(len(body), 256)])))
+ return nil, fmt.Errorf(
+ "decode response: unrecognized shape: %s",
+ strings.TrimSpace(string(body[:min(len(body), 256)])),
+ )
}
diff --git a/cmd/picoclaw-launcher-tui/ui/schemes.go b/cmd/picoclaw-launcher-tui/ui/schemes.go
index 70375eccc..e38d7fa86 100644
--- a/cmd/picoclaw-launcher-tui/ui/schemes.go
+++ b/cmd/picoclaw-launcher-tui/ui/schemes.go
@@ -10,6 +10,7 @@ import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
+
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
@@ -17,8 +18,13 @@ func (a *App) newSchemesPage() tview.Primitive {
table := tview.NewTable().
SetBorders(false).
SetSelectable(true, false)
- table.SetBorder(true).SetTitle(" [#00f0ff::b] PROVIDER SCHEMES ").SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff))
- table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)))
+ table.SetBorder(true).
+ SetTitle(" [#00f0ff::b] PROVIDER SCHEMES ").
+ SetTitleColor(tcell.NewHexColor(0x00f0ff)).
+ SetBorderColor(tcell.NewHexColor(0x00f0ff))
+ table.SetSelectedStyle(
+ tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
+ )
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
rowToIdx := func(row int) int { return row / 2 }
@@ -165,7 +171,11 @@ func (a *App) newSchemesPage() tview.Primitive {
return event
})
- return a.buildShell("schemes", table, " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ")
+ return a.buildShell(
+ "schemes",
+ table,
+ " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ",
+ )
}
func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) {
@@ -220,7 +230,10 @@ func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)
a.hideModal("scheme-form")
})
- form.SetBorder(true).SetTitle(" [::b]" + title + " ").SetTitleColor(tcell.NewHexColor(0x39ff14)).SetBorderColor(tcell.NewHexColor(0x00f0ff))
+ form.SetBorder(true).
+ SetTitle(" [::b]" + title + " ").
+ SetTitleColor(tcell.NewHexColor(0x39ff14)).
+ SetBorderColor(tcell.NewHexColor(0x00f0ff))
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
diff --git a/cmd/picoclaw-launcher-tui/ui/users.go b/cmd/picoclaw-launcher-tui/ui/users.go
index 4a877d3c7..b00fc8982 100644
--- a/cmd/picoclaw-launcher-tui/ui/users.go
+++ b/cmd/picoclaw-launcher-tui/ui/users.go
@@ -10,6 +10,7 @@ import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
+
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
@@ -17,8 +18,13 @@ func (a *App) newUsersPage(schemeName string) tview.Primitive {
table := tview.NewTable().
SetBorders(false).
SetSelectable(true, false)
- table.SetBorder(true).SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)).SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff))
- table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)))
+ table.SetBorder(true).
+ SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)).
+ SetTitleColor(tcell.NewHexColor(0x00f0ff)).
+ SetBorderColor(tcell.NewHexColor(0x00f0ff))
+ table.SetSelectedStyle(
+ tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
+ )
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
visibleUsers := func() []tuicfg.User {
@@ -179,7 +185,11 @@ func (a *App) newUsersPage(schemeName string) tview.Primitive {
return event
})
- return a.buildShell("users", table, " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ")
+ return a.buildShell(
+ "users",
+ table,
+ " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ",
+ )
}
func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) {
@@ -229,7 +239,10 @@ func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func
a.hideModal("user-form")
})
- form.SetBorder(true).SetTitle(" [::b]" + title + " ").SetTitleColor(tcell.NewHexColor(0x39ff14)).SetBorderColor(tcell.NewHexColor(0x00f0ff))
+ form.SetBorder(true).
+ SetTitle(" [::b]" + title + " ").
+ SetTitleColor(tcell.NewHexColor(0x39ff14)).
+ SetBorderColor(tcell.NewHexColor(0x00f0ff))
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
From 544940807f4eee0dc8dd136a79a85aa2eef23e87 Mon Sep 17 00:00:00 2001
From: Amir Mamaghani <67312799+amirmamaghani@users.noreply.github.com>
Date: Fri, 20 Mar 2026 13:43:40 +0100
Subject: [PATCH 38/44] feat(pico): add pico_client outbound WebSocket channel
(#1198)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(pico): add pico_client outbound WebSocket channel
Add a client-mode counterpart to the existing pico server channel.
pico_client connects to a remote Pico Protocol WebSocket server,
enabling picoclaw to bridge messages with external Pico-compatible
services.
Includes config, factory registration, manager wiring, 8 unit tests,
and a minimal echo-server example for interactive testing.
* fix(pico): address PR #1198 review — goroutine leak, race, auth
- Add per-connection context cancel to picoConn to prevent pingLoop
goroutine leak on disconnect
- Re-acquire mutex in StartTyping stop closure to avoid stale conn race
- Remove query-param token auth from echo server (header-only)
- Move ListenAndServe to main goroutine where log.Fatal is safe
Co-Authored-By: Claude Opus 4.6
* fix: replace ConsumeInbound with InboundChan select in client test
MessageBus does not expose a ConsumeInbound method. Use a select on
InboundChan() with context cancellation, matching the pattern used in
the bus package tests.
Co-Authored-By: Claude Opus 4.6 (1M context)
---------
Co-authored-by: Claude Opus 4.6
---
config/config.example.json | 19 ++
examples/pico-echo-server/README.md | 47 ++++
examples/pico-echo-server/main.go | 160 ++++++++++++++
pkg/channels/manager.go | 4 +
pkg/channels/pico/client.go | 319 ++++++++++++++++++++++++++++
pkg/channels/pico/client_test.go | 264 +++++++++++++++++++++++
pkg/channels/pico/init.go | 3 +
pkg/channels/pico/pico.go | 4 +
pkg/config/config.go | 11 +
9 files changed, 831 insertions(+)
create mode 100644 examples/pico-echo-server/README.md
create mode 100644 examples/pico-echo-server/main.go
create mode 100644 pkg/channels/pico/client.go
create mode 100644 pkg/channels/pico/client_test.go
diff --git a/config/config.example.json b/config/config.example.json
index 221e89491..69ac062ac 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -213,6 +213,25 @@
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
"reasoning_channel_id": ""
},
+ "pico": {
+ "enabled": false,
+ "token": "YOUR_PICO_TOKEN",
+ "allow_token_query": false,
+ "allow_origins": [],
+ "ping_interval": 30,
+ "read_timeout": 60,
+ "max_connections": 100,
+ "allow_from": []
+ },
+ "pico_client": {
+ "enabled": false,
+ "url": "wss://remote-pico-server/pico/ws",
+ "token": "YOUR_PICO_TOKEN",
+ "session_id": "",
+ "ping_interval": 30,
+ "read_timeout": 60,
+ "allow_from": []
+ },
"irc": {
"enabled": false,
"server": "irc.libera.chat:6697",
diff --git a/examples/pico-echo-server/README.md b/examples/pico-echo-server/README.md
new file mode 100644
index 000000000..f6b5d8020
--- /dev/null
+++ b/examples/pico-echo-server/README.md
@@ -0,0 +1,47 @@
+# pico-echo-server
+
+Minimal Pico Protocol WebSocket server for testing the `pico_client` channel.
+
+## Usage
+
+```bash
+go run ./examples/pico-echo-server -addr :9090 -token secret
+```
+
+### Flags
+
+| Flag | Default | Description |
+|----------|---------|------------------------------------|
+| `-addr` | `:9090` | Listen address |
+| `-token` | (none) | Auth token; empty disables auth |
+
+## How it works
+
+- Listens for WebSocket connections at `/ws`
+- Authenticates via `Authorization: Bearer ` header or `?token=` query param
+- Prints received `message.send` content to stdout
+- Responds to `ping` with `pong`
+- Lines typed into stdin are broadcast as `message.create` to all connected clients
+
+## Testing with pico_client
+
+1. Start the server:
+ ```bash
+ go run ./examples/pico-echo-server -token mytoken
+ ```
+
+2. Configure `pico_client` in your `config.json`:
+ ```json
+ {
+ "channels": {
+ "pico_client": {
+ "enabled": true,
+ "url": "ws://localhost:9090/ws",
+ "token": "mytoken",
+ "session_id": "test-session"
+ }
+ }
+ }
+ ```
+
+3. Start picoclaw — the client connects and you can exchange messages interactively via stdin/stdout.
diff --git a/examples/pico-echo-server/main.go b/examples/pico-echo-server/main.go
new file mode 100644
index 000000000..46970fb34
--- /dev/null
+++ b/examples/pico-echo-server/main.go
@@ -0,0 +1,160 @@
+// pico-echo-server is a minimal Pico Protocol WebSocket server for testing
+// the pico_client channel. It accepts connections, prints received messages
+// to stdout, and forwards stdin lines as message.create to all connected clients.
+//
+// Usage:
+//
+// go run ./examples/pico-echo-server -addr :9090 -token secret
+//
+// Then configure pico_client with url=ws://localhost:9090/ws&token=secret.
+package main
+
+import (
+ "bufio"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+
+type picoMessage struct {
+ Type string `json:"type"`
+ ID string `json:"id,omitempty"`
+ SessionID string `json:"session_id,omitempty"`
+ Timestamp int64 `json:"timestamp,omitempty"`
+ Payload map[string]any `json:"payload,omitempty"`
+}
+
+var upgrader = websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
+
+type server struct {
+ token string
+ mu sync.Mutex
+ conns map[*websocket.Conn]string // conn → sessionID
+}
+
+func (s *server) handleWS(w http.ResponseWriter, r *http.Request) {
+ if s.token != "" {
+ auth := r.Header.Get("Authorization")
+ if auth != "Bearer "+s.token {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ }
+
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ log.Printf("upgrade: %v", err)
+ return
+ }
+
+ sessionID := r.URL.Query().Get("session_id")
+ if sessionID == "" {
+ sessionID = fmt.Sprintf("sess-%d", time.Now().UnixMilli())
+ }
+
+ s.mu.Lock()
+ s.conns[conn] = sessionID
+ s.mu.Unlock()
+
+ log.Printf("[+] client connected (session=%s)", sessionID)
+
+ defer func() {
+ s.mu.Lock()
+ delete(s.conns, conn)
+ s.mu.Unlock()
+ conn.Close()
+ log.Printf("[-] client disconnected (session=%s)", sessionID)
+ }()
+
+ for {
+ _, raw, err := conn.ReadMessage()
+ if err != nil {
+ if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
+ log.Printf("read error: %v", err)
+ }
+ return
+ }
+
+ var msg picoMessage
+ if err := json.Unmarshal(raw, &msg); err != nil {
+ log.Printf("bad json: %v", err)
+ continue
+ }
+
+ switch msg.Type {
+ case "ping":
+ pong := picoMessage{Type: "pong", ID: msg.ID, Timestamp: time.Now().UnixMilli()}
+ conn.WriteJSON(pong)
+
+ case "message.send":
+ content, _ := msg.Payload["content"].(string)
+ fmt.Printf("[%s] %s\n", sessionID, content)
+
+ case "typing.start":
+ log.Printf("[%s] typing...", sessionID)
+
+ case "typing.stop":
+ log.Printf("[%s] stopped typing", sessionID)
+
+ default:
+ log.Printf("[%s] unknown type: %s", sessionID, msg.Type)
+ }
+ }
+}
+
+func (s *server) broadcast(content string) {
+ msg := picoMessage{
+ Type: "message.create",
+ Timestamp: time.Now().UnixMilli(),
+ Payload: map[string]any{"content": content},
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ for conn, sid := range s.conns {
+ msg.SessionID = sid
+ if err := conn.WriteJSON(msg); err != nil {
+ log.Printf("write to %s failed: %v", sid, err)
+ }
+ }
+}
+
+func main() {
+ addr := flag.String("addr", ":9090", "listen address")
+ token := flag.String("token", "", "auth token (empty = no auth)")
+ flag.Parse()
+
+ s := &server{
+ token: *token,
+ conns: make(map[*websocket.Conn]string),
+ }
+
+ http.HandleFunc("/ws", s.handleWS)
+
+ log.Printf("listening on %s", *addr)
+ log.Printf("connect with: ws://localhost%s/ws", *addr)
+ fmt.Println("Type messages to send to connected clients (Ctrl+C to quit):")
+
+ go func() {
+ scanner := bufio.NewScanner(os.Stdin)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ continue
+ }
+ s.broadcast(line)
+ log.Printf("[server] sent: %s", line)
+ }
+ }()
+
+ log.Fatal(http.ListenAndServe(*addr, nil))
+}
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index c980daf66..741fad53e 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -323,6 +323,10 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
m.initChannel("pico", "Pico")
}
+ if channels.PicoClient.Enabled && channels.PicoClient.URL != "" {
+ m.initChannel("pico_client", "Pico Client")
+ }
+
if channels.IRC.Enabled && channels.IRC.Server != "" {
m.initChannel("irc", "IRC")
}
diff --git a/pkg/channels/pico/client.go b/pkg/channels/pico/client.go
new file mode 100644
index 000000000..2c335050d
--- /dev/null
+++ b/pkg/channels/pico/client.go
@@ -0,0 +1,319 @@
+package pico
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/gorilla/websocket"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/identity"
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+// PicoClientChannel connects to a remote Pico Protocol WebSocket server.
+type PicoClientChannel struct {
+ *channels.BaseChannel
+ config config.PicoClientConfig
+ conn *picoConn
+ mu sync.Mutex
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+// NewPicoClientChannel creates a new Pico Protocol client channel.
+func NewPicoClientChannel(
+ cfg config.PicoClientConfig,
+ messageBus *bus.MessageBus,
+) (*PicoClientChannel, error) {
+ if cfg.URL == "" {
+ return nil, fmt.Errorf("pico_client url is required")
+ }
+
+ base := channels.NewBaseChannel("pico_client", cfg, messageBus, cfg.AllowFrom)
+
+ return &PicoClientChannel{
+ BaseChannel: base,
+ config: cfg,
+ }, nil
+}
+
+// Start dials the remote server and begins reading.
+func (c *PicoClientChannel) Start(ctx context.Context) error {
+ logger.InfoC("pico_client", "Starting Pico Client channel")
+ c.ctx, c.cancel = context.WithCancel(ctx)
+
+ if err := c.dial(); err != nil {
+ c.cancel()
+ return fmt.Errorf("pico_client initial connect: %w", err)
+ }
+
+ c.SetRunning(true)
+ go c.reconnectLoop()
+
+ logger.InfoCF("pico_client", "Connected", map[string]any{"url": c.config.URL})
+ return nil
+}
+
+// Stop closes the connection.
+func (c *PicoClientChannel) Stop(ctx context.Context) error {
+ logger.InfoC("pico_client", "Stopping Pico Client channel")
+ c.SetRunning(false)
+ if c.cancel != nil {
+ c.cancel()
+ }
+ c.mu.Lock()
+ if c.conn != nil {
+ c.conn.close()
+ }
+ c.mu.Unlock()
+ logger.InfoC("pico_client", "Pico Client channel stopped")
+ return nil
+}
+
+func (c *PicoClientChannel) dial() error {
+ header := http.Header{}
+ if c.config.Token != "" {
+ header.Set("Authorization", "Bearer "+c.config.Token)
+ }
+
+ ws, resp, err := websocket.DefaultDialer.DialContext(c.ctx, c.config.URL, header)
+ if resp != nil && resp.Body != nil {
+ resp.Body.Close()
+ }
+ if err != nil {
+ return err
+ }
+
+ connCtx, connCancel := context.WithCancel(c.ctx)
+
+ pc := &picoConn{
+ id: uuid.New().String(),
+ conn: ws,
+ sessionID: c.config.SessionID,
+ cancel: connCancel,
+ }
+ if pc.sessionID == "" {
+ pc.sessionID = uuid.New().String()
+ }
+
+ c.mu.Lock()
+ c.conn = pc
+ c.mu.Unlock()
+
+ go c.readLoop(connCtx, pc)
+ return nil
+}
+
+// reconnectLoop re-dials when the connection drops.
+func (c *PicoClientChannel) reconnectLoop() {
+ for {
+ select {
+ case <-c.ctx.Done():
+ return
+ default:
+ }
+
+ c.mu.Lock()
+ pc := c.conn
+ c.mu.Unlock()
+
+ if pc == nil || pc.closed.Load() {
+ backoff := 5 * time.Second
+ logger.InfoC("pico_client", "Reconnecting...")
+ if err := c.dial(); err != nil {
+ logger.WarnCF("pico_client", "Reconnect failed", map[string]any{
+ "error": err.Error(),
+ })
+ select {
+ case <-c.ctx.Done():
+ return
+ case <-time.After(backoff):
+ }
+ continue
+ }
+ logger.InfoC("pico_client", "Reconnected")
+ }
+
+ select {
+ case <-c.ctx.Done():
+ return
+ case <-time.After(1 * time.Second):
+ }
+ }
+}
+
+func (c *PicoClientChannel) readLoop(connCtx context.Context, pc *picoConn) {
+ defer pc.close()
+
+ readTimeout := time.Duration(c.config.ReadTimeout) * time.Second
+ if readTimeout <= 0 {
+ readTimeout = 60 * time.Second
+ }
+
+ _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
+ pc.conn.SetPongHandler(func(string) error {
+ return pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
+ })
+
+ pingInterval := time.Duration(c.config.PingInterval) * time.Second
+ if pingInterval <= 0 {
+ pingInterval = 30 * time.Second
+ }
+ go c.pingLoop(connCtx, pc, pingInterval)
+
+ for {
+ select {
+ case <-connCtx.Done():
+ return
+ default:
+ }
+
+ _, raw, err := pc.conn.ReadMessage()
+ if err != nil {
+ if websocket.IsUnexpectedCloseError(
+ err,
+ websocket.CloseGoingAway,
+ websocket.CloseNormalClosure,
+ ) {
+ logger.DebugCF("pico_client", "Read error", map[string]any{
+ "error": err.Error(),
+ })
+ }
+ return
+ }
+
+ _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
+
+ var msg PicoMessage
+ if err := json.Unmarshal(raw, &msg); err != nil {
+ continue
+ }
+
+ c.handleInbound(pc, msg)
+ }
+}
+
+func (c *PicoClientChannel) pingLoop(connCtx context.Context, pc *picoConn, interval time.Duration) {
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-connCtx.Done():
+ return
+ case <-ticker.C:
+ if pc.closed.Load() {
+ return
+ }
+ pc.writeMu.Lock()
+ err := pc.conn.WriteMessage(websocket.PingMessage, nil)
+ pc.writeMu.Unlock()
+ if err != nil {
+ return
+ }
+ }
+ }
+}
+
+// handleInbound processes messages from the remote server.
+// In client mode the server sends message.create (responses) and the client
+// sends message.send (user input). We treat message.create from the server
+// as inbound user messages to feed into the agent loop.
+func (c *PicoClientChannel) handleInbound(pc *picoConn, msg PicoMessage) {
+ switch msg.Type {
+ case TypePong:
+ // response to our ping, ignore
+ case TypeMessageCreate:
+ // Server sent us a message — treat as inbound
+ c.handleServerMessage(pc, msg)
+ default:
+ logger.DebugCF("pico_client", "Ignoring message type", map[string]any{
+ "type": msg.Type,
+ })
+ }
+}
+
+func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) {
+ content, _ := msg.Payload["content"].(string)
+ if strings.TrimSpace(content) == "" {
+ return
+ }
+
+ sessionID := msg.SessionID
+ if sessionID == "" {
+ sessionID = pc.sessionID
+ }
+
+ chatID := "pico_client:" + sessionID
+ senderID := "pico-remote"
+ peer := bus.Peer{Kind: "direct", ID: chatID}
+
+ sender := bus.SenderInfo{
+ Platform: "pico_client",
+ PlatformID: senderID,
+ CanonicalID: identity.BuildCanonicalID("pico_client", senderID),
+ }
+
+ if !c.IsAllowedSender(sender) {
+ return
+ }
+
+ c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, map[string]string{
+ "platform": "pico_client",
+ "session_id": sessionID,
+ }, sender)
+}
+
+// Send sends a message to the remote server.
+func (c *PicoClientChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+ c.mu.Lock()
+ pc := c.conn
+ c.mu.Unlock()
+ if pc == nil || pc.closed.Load() {
+ return channels.ErrSendFailed
+ }
+
+ outMsg := newMessage(TypeMessageSend, map[string]any{
+ "content": msg.Content,
+ })
+ outMsg.SessionID = strings.TrimPrefix(msg.ChatID, "pico_client:")
+ return pc.writeJSON(outMsg)
+}
+
+// StartTyping implements channels.TypingCapable.
+func (c *PicoClientChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
+ c.mu.Lock()
+ pc := c.conn
+ c.mu.Unlock()
+ if pc == nil || pc.closed.Load() {
+ return func() {}, nil
+ }
+
+ startMsg := newMessage(TypeTypingStart, nil)
+ startMsg.SessionID = strings.TrimPrefix(chatID, "pico_client:")
+ if err := pc.writeJSON(startMsg); err != nil {
+ return func() {}, err
+ }
+ return func() {
+ c.mu.Lock()
+ currentPC := c.conn
+ c.mu.Unlock()
+ if currentPC == nil {
+ return
+ }
+ stopMsg := newMessage(TypeTypingStop, nil)
+ stopMsg.SessionID = strings.TrimPrefix(chatID, "pico_client:")
+ currentPC.writeJSON(stopMsg)
+ }, nil
+}
diff --git a/pkg/channels/pico/client_test.go b/pkg/channels/pico/client_test.go
new file mode 100644
index 000000000..118c9abea
--- /dev/null
+++ b/pkg/channels/pico/client_test.go
@@ -0,0 +1,264 @@
+package pico
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gorilla/websocket"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestNewPicoClientChannel_MissingURL(t *testing.T) {
+ _, err := NewPicoClientChannel(config.PicoClientConfig{}, bus.NewMessageBus())
+ if err == nil {
+ t.Fatal("expected error for missing URL")
+ }
+ if !strings.Contains(err.Error(), "url is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestNewPicoClientChannel_OK(t *testing.T) {
+ ch, err := NewPicoClientChannel(config.PicoClientConfig{
+ URL: "ws://localhost:9999/ws",
+ }, bus.NewMessageBus())
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if ch.Name() != "pico_client" {
+ t.Fatalf("name = %q, want pico_client", ch.Name())
+ }
+}
+
+func TestSend_NotRunning(t *testing.T) {
+ ch, err := NewPicoClientChannel(config.PicoClientConfig{
+ URL: "ws://localhost:9999/ws",
+ }, bus.NewMessageBus())
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = ch.Send(context.Background(), bus.OutboundMessage{Content: "hi"})
+ if !errors.Is(err, channels.ErrNotRunning) {
+ t.Fatalf("expected ErrNotRunning, got %v", err)
+ }
+}
+
+// testServer starts a WS server that echoes message.send back as message.create.
+func testServer(t *testing.T, token string) *httptest.Server {
+ t.Helper()
+ upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
+
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if token != "" {
+ auth := r.Header.Get("Authorization")
+ if auth != "Bearer "+token {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ }
+
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ t.Logf("upgrade error: %v", err)
+ return
+ }
+ defer conn.Close()
+
+ for {
+ _, raw, err := conn.ReadMessage()
+ if err != nil {
+ return
+ }
+
+ var msg PicoMessage
+ if err := json.Unmarshal(raw, &msg); err != nil {
+ continue
+ }
+
+ if msg.Type == TypeMessageSend {
+ reply := newMessage(TypeMessageCreate, msg.Payload)
+ reply.SessionID = msg.SessionID
+ if err := conn.WriteJSON(reply); err != nil {
+ return
+ }
+ }
+ }
+ }))
+}
+
+func wsURL(httpURL string) string {
+ return "ws" + strings.TrimPrefix(httpURL, "http")
+}
+
+func TestClientChannel_ConnectAndSend(t *testing.T) {
+ srv := testServer(t, "test-token")
+ defer srv.Close()
+
+ mb := bus.NewMessageBus()
+ ch, err := NewPicoClientChannel(config.PicoClientConfig{
+ URL: wsURL(srv.URL),
+ Token: "test-token",
+ SessionID: "sess-1",
+ PingInterval: 60,
+ ReadTimeout: 10,
+ }, mb)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ if err = ch.Start(ctx); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ defer ch.Stop(ctx)
+
+ // Send a message
+ err = ch.Send(ctx, bus.OutboundMessage{
+ ChatID: "pico_client:sess-1",
+ Content: "hello",
+ })
+ if err != nil {
+ t.Fatalf("Send: %v", err)
+ }
+}
+
+func TestClientChannel_AuthFailure(t *testing.T) {
+ srv := testServer(t, "correct-token")
+ defer srv.Close()
+
+ ch, err := NewPicoClientChannel(config.PicoClientConfig{
+ URL: wsURL(srv.URL),
+ Token: "wrong-token",
+ }, bus.NewMessageBus())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ err = ch.Start(ctx)
+ if err == nil {
+ ch.Stop(ctx)
+ t.Fatal("expected auth failure")
+ }
+}
+
+func TestClientChannel_ReceivesServerMessage(t *testing.T) {
+ srv := testServer(t, "")
+ defer srv.Close()
+
+ mb := bus.NewMessageBus()
+
+ ch, err := NewPicoClientChannel(config.PicoClientConfig{
+ URL: wsURL(srv.URL),
+ SessionID: "sess-echo",
+ ReadTimeout: 10,
+ }, mb)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ if err = ch.Start(ctx); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ defer ch.Stop(ctx)
+
+ // Send a message; the echo server replies with message.create
+ err = ch.Send(ctx, bus.OutboundMessage{
+ ChatID: "pico_client:sess-echo",
+ Content: "ping",
+ })
+ if err != nil {
+ t.Fatalf("Send: %v", err)
+ }
+
+ // The echoed message.create is processed by handleServerMessage which
+ // calls HandleMessage → PublishInbound. Consume it from the bus.
+ select {
+ case msg := <-mb.InboundChan():
+ if msg.Content != "ping" {
+ t.Fatalf("received = %q, want %q", msg.Content, "ping")
+ }
+ case <-ctx.Done():
+ t.Fatal("timed out waiting for echoed message")
+ }
+}
+
+func TestClientChannel_StartTyping(t *testing.T) {
+ srv := testServer(t, "")
+ defer srv.Close()
+
+ ch, err := NewPicoClientChannel(config.PicoClientConfig{
+ URL: wsURL(srv.URL),
+ SessionID: "sess-type",
+ ReadTimeout: 10,
+ }, bus.NewMessageBus())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ if err = ch.Start(ctx); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ defer ch.Stop(ctx)
+
+ stop, err := ch.StartTyping(ctx, "pico_client:sess-type")
+ if err != nil {
+ t.Fatalf("StartTyping: %v", err)
+ }
+ stop() // should not panic
+}
+
+func TestSend_ClosedConnection(t *testing.T) {
+ srv := testServer(t, "")
+ defer srv.Close()
+
+ ch, err := NewPicoClientChannel(config.PicoClientConfig{
+ URL: wsURL(srv.URL),
+ SessionID: "sess-close",
+ ReadTimeout: 10,
+ }, bus.NewMessageBus())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ if err = ch.Start(ctx); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+
+ // Force close the underlying connection
+ ch.mu.Lock()
+ ch.conn.close()
+ ch.mu.Unlock()
+
+ err = ch.Send(ctx, bus.OutboundMessage{
+ ChatID: "pico_client:sess-close",
+ Content: "should fail",
+ })
+ if !errors.Is(err, channels.ErrSendFailed) {
+ t.Fatalf("expected ErrSendFailed, got %v", err)
+ }
+
+ ch.Stop(ctx)
+}
diff --git a/pkg/channels/pico/init.go b/pkg/channels/pico/init.go
index 96d764418..0319279d8 100644
--- a/pkg/channels/pico/init.go
+++ b/pkg/channels/pico/init.go
@@ -10,4 +10,7 @@ func init() {
channels.RegisterFactory("pico", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewPicoChannel(cfg.Channels.Pico, b)
})
+ channels.RegisterFactory("pico_client", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ return NewPicoClientChannel(cfg.Channels.PicoClient, b)
+ })
}
diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go
index 206e71f92..77e7bbdb6 100644
--- a/pkg/channels/pico/pico.go
+++ b/pkg/channels/pico/pico.go
@@ -27,6 +27,7 @@ type picoConn struct {
sessionID string
writeMu sync.Mutex
closed atomic.Bool
+ cancel context.CancelFunc // cancels per-connection goroutines (e.g. pingLoop)
}
// writeJSON sends a JSON message to the connection with write locking.
@@ -42,6 +43,9 @@ func (pc *picoConn) writeJSON(v any) error {
// close closes the connection.
func (pc *picoConn) close() {
if pc.closed.CompareAndSwap(false, true) {
+ if pc.cancel != nil {
+ pc.cancel()
+ }
pc.conn.Close()
}
}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 33a5db8ae..f524e952a 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -297,6 +297,7 @@ type ChannelsConfig struct {
WeComApp WeComAppConfig `json:"wecom_app"`
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
Pico PicoConfig `json:"pico"`
+ PicoClient PicoClientConfig `json:"pico_client"`
IRC IRCConfig `json:"irc"`
}
@@ -504,6 +505,16 @@ type PicoConfig struct {
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
}
+type PicoClientConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ENABLED"`
+ URL string `json:"url" env:"PICOCLAW_CHANNELS_PICO_CLIENT_URL"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_CLIENT_TOKEN"`
+ SessionID string `json:"session_id,omitempty"`
+ PingInterval int `json:"ping_interval,omitempty"`
+ ReadTimeout int `json:"read_timeout,omitempty"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ALLOW_FROM"`
+}
+
type IRCConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"`
Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"`
From 71134babb9bd19a668bf5d60f1b3d2b5e95b07c1 Mon Sep 17 00:00:00 2001
From: Amir Mamaghani <67312799+amirmamaghani@users.noreply.github.com>
Date: Fri, 20 Mar 2026 14:04:14 +0100
Subject: [PATCH 39/44] feat(telegram): stream LLM responses via
sendMessageDraft (#1101)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* fix(picoclaw): add missing closing brace for StreamingProvider interface
Co-Authored-By: Claude Opus 4.6
* fix: resolve golangci-lint formatting issues
Fix gci import ordering in telegram and anthropic provider, and break
long function signature in openai_compat provider to satisfy golines.
* fix: address code review feedback on streaming PR
- Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer
to prevent type drift across packages
- Increase SSE scanner buffer to 10MB max to handle large single-line
responses that exceed bufio.Scanner's 64KB default
- Switch draftID generation from math/rand to crypto/rand for
collision-resistant random IDs
- Add context cancellation check in SSE parsing loop so cancelled
streams stop processing immediately
- Log Finalize failures with chat_id and content length for debugging
silent message delivery failures
* feat: make streaming throttle interval and min growth configurable
Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200)
into StreamingConfig so they can be tuned per deployment via config
or environment variables.
* fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream
These two functions called undefined parseChatID. Use
parseTelegramChatID with _ for the unused threadID instead of adding
a wrapper function. Fixes all three CI checks.
Co-Authored-By: Claude Opus 4.6
* fix(streaming): set streamActive only after successful Finalize
Move onFinalize hook to run after Streamer.Finalize succeeds, so that
if Finalize fails the streamActive flag stays false and the regular
placeholder fallback path remains available.
Addresses review feedback from @alexhoshina.
Co-Authored-By: Claude Opus 4.6 (1M context)
---------
Co-authored-by: Claude Opus 4.6
---
config/config.example.json | 5 +-
pkg/agent/loop.go | 34 ++++
pkg/bus/bus.go | 38 +++-
pkg/channels/base.go | 8 +-
pkg/channels/interfaces.go | 18 ++
pkg/channels/manager.go | 70 ++++++-
pkg/channels/telegram/telegram.go | 123 +++++++++++++
pkg/config/config.go | 7 +
pkg/config/defaults.go | 1 +
pkg/providers/http_provider.go | 13 ++
pkg/providers/openai_compat/provider.go | 231 ++++++++++++++++++++++--
pkg/providers/types.go | 14 ++
12 files changed, 535 insertions(+), 27 deletions(-)
diff --git a/config/config.example.json b/config/config.example.json
index 69ac062ac..81c9014ec 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -84,7 +84,10 @@
"proxy": "",
"allow_from": ["YOUR_USER_ID"],
"use_markdown_v2": false,
- "reasoning_channel_id": ""
+ "reasoning_channel_id": "",
+ "streaming": {
+ "enabled": true
+ }
},
"discord": {
"enabled": false,
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index ef2b9e28f..1ca5db5b8 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -1026,6 +1026,7 @@ func (al *AgentLoop) handleReasoning(
}
// runLLMIteration executes the LLM call loop with tool handling.
+// Returns (finalContent, iteration, error).
func (al *AgentLoop) runLLMIteration(
ctx context.Context,
agent *AgentInstance,
@@ -1035,6 +1036,13 @@ func (al *AgentLoop) runLLMIteration(
iteration := 0
var finalContent string
+ // Check if both the provider and channel support streaming
+ streamProvider, providerCanStream := agent.Provider.(providers.StreamingProvider)
+ var streamer bus.Streamer
+ if providerCanStream && !opts.NoHistory && !constants.IsInternalChannel(opts.Channel) {
+ streamer, _ = al.bus.GetStreamer(ctx, opts.Channel, opts.ChatID)
+ }
+
// Determine effective model tier for this conversation turn.
// selectCandidates evaluates routing once and the decision is sticky for
// all tool-follow-up iterations within the same turn so that a multi-step
@@ -1116,6 +1124,16 @@ func (al *AgentLoop) runLLMIteration(
al.activeRequests.Add(1)
defer al.activeRequests.Done()
+ // Use streaming when available (streamer obtained, provider supports it)
+ if streamer != nil && streamProvider != nil {
+ return streamProvider.ChatStream(
+ ctx, messages, providerToolDefs, activeModel, llmOpts,
+ func(accumulated string) {
+ streamer.Update(ctx, accumulated)
+ },
+ )
+ }
+
if len(activeCandidates) > 1 && al.fallback != nil {
fbResult, fbErr := al.fallback.Execute(
ctx,
@@ -1243,15 +1261,31 @@ func (al *AgentLoop) runLLMIteration(
if finalContent == "" && response.ReasoningContent != "" {
finalContent = response.ReasoningContent
}
+
+ // If we were streaming, finalize the message (sends the permanent message)
+ if streamer != nil {
+ if err := streamer.Finalize(ctx, finalContent); err != nil {
+ logger.WarnCF("agent", "Stream finalize failed", map[string]any{
+ "error": err.Error(),
+ })
+ }
+ }
+
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"content_chars": len(finalContent),
+ "streamed": streamer != nil,
})
break
}
+ // Tool calls detected — cancel any active stream (draft auto-expires)
+ if streamer != nil {
+ streamer.Cancel(ctx)
+ }
+
normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls))
for _, tc := range response.ToolCalls {
normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc))
diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go
index 3d08bda4f..37fcb74c5 100644
--- a/pkg/bus/bus.go
+++ b/pkg/bus/bus.go
@@ -14,15 +14,32 @@ var ErrBusClosed = errors.New("message bus closed")
const defaultBusBufferSize = 64
+// StreamDelegate is implemented by the channel Manager to provide streaming
+// capabilities to the agent loop without tight coupling.
+type StreamDelegate interface {
+ // GetStreamer returns a Streamer for the given channel+chatID if the channel
+ // supports streaming. Returns nil, false if streaming is unavailable.
+ GetStreamer(ctx context.Context, channel, chatID string) (Streamer, bool)
+}
+
+// Streamer pushes incremental content to a streaming-capable channel.
+// Defined here so the agent loop can use it without importing pkg/channels.
+type Streamer interface {
+ Update(ctx context.Context, content string) error
+ Finalize(ctx context.Context, content string) error
+ Cancel(ctx context.Context)
+}
+
type MessageBus struct {
inbound chan InboundMessage
outbound chan OutboundMessage
outboundMedia chan OutboundMediaMessage
- closeOnce sync.Once
- done chan struct{}
- closed atomic.Bool
- wg sync.WaitGroup
+ closeOnce sync.Once
+ done chan struct{}
+ closed atomic.Bool
+ wg sync.WaitGroup
+ streamDelegate atomic.Value // stores StreamDelegate
}
func NewMessageBus() *MessageBus {
@@ -86,6 +103,19 @@ func (mb *MessageBus) OutboundMediaChan() <-chan OutboundMediaMessage {
return mb.outboundMedia
}
+// SetStreamDelegate registers a StreamDelegate (typically the channel Manager).
+func (mb *MessageBus) SetStreamDelegate(d StreamDelegate) {
+ mb.streamDelegate.Store(d)
+}
+
+// GetStreamer returns a Streamer for the given channel+chatID via the delegate.
+func (mb *MessageBus) GetStreamer(ctx context.Context, channel, chatID string) (Streamer, bool) {
+ if d, ok := mb.streamDelegate.Load().(StreamDelegate); ok && d != nil {
+ return d.GetStreamer(ctx, channel, chatID)
+ }
+ return nil, false
+}
+
func (mb *MessageBus) Close() {
mb.closeOnce.Do(func() {
// notify all blocked publishers to exit
diff --git a/pkg/channels/base.go b/pkg/channels/base.go
index edb5b6f08..882e72d08 100644
--- a/pkg/channels/base.go
+++ b/pkg/channels/base.go
@@ -275,14 +275,18 @@ func (c *BaseChannel) HandleMessage(
// Auto-trigger typing indicator, message reaction, and placeholder before publishing.
// Each capability is independent — all three may fire for the same message.
+ // Note: even when streaming is available, we still show typing + placeholder on inbound.
+ // If streaming actually activates, preSend will skip the placeholder edit (streamActive map)
+ // and the typing stop will still be called. This avoids the problem of compile-time interface
+ // checks incorrectly skipping indicators when streaming may not work at runtime.
if c.owner != nil && c.placeholderRecorder != nil {
- // Typing — independent pipeline
+ // Typing
if tc, ok := c.owner.(TypingCapable); ok {
if stop, err := tc.StartTyping(ctx, chatID); err == nil {
c.placeholderRecorder.RecordTypingStop(c.name, chatID, stop)
}
}
- // Reaction — independent pipeline
+ // Reaction
if rc, ok := c.owner.(ReactionCapable); ok && messageID != "" {
if undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil {
c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo)
diff --git a/pkg/channels/interfaces.go b/pkg/channels/interfaces.go
index b3a493761..0cfd435b0 100644
--- a/pkg/channels/interfaces.go
+++ b/pkg/channels/interfaces.go
@@ -3,6 +3,7 @@ package channels
import (
"context"
+ "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/commands"
)
@@ -19,6 +20,11 @@ type MessageEditor interface {
EditMessage(ctx context.Context, chatID string, messageID string, content string) error
}
+// MessageDeleter — channels that can delete a message by ID.
+type MessageDeleter interface {
+ DeleteMessage(ctx context.Context, chatID string, messageID string) error
+}
+
// ReactionCapable — channels that can add a reaction (e.g. 👀) to an inbound message.
// ReactToMessage adds a reaction and returns an undo function to remove it.
// The undo function MUST be idempotent and safe to call multiple times.
@@ -35,6 +41,18 @@ type PlaceholderCapable interface {
SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error)
}
+// StreamingCapable — channels that can show partial LLM output in real-time.
+// The channel SHOULD gracefully degrade if the platform rejects streaming
+// (e.g. Telegram bot without forum mode). In that case, Update becomes a no-op
+// and Finalize still delivers the final message.
+type StreamingCapable interface {
+ BeginStream(ctx context.Context, chatID string) (Streamer, error)
+}
+
+// Streamer is defined in pkg/bus to avoid circular imports.
+// This alias keeps channel implementations using channels.Streamer unchanged.
+type Streamer = bus.Streamer
+
// PlaceholderRecorder is injected into channels by Manager.
// Channels call these methods on inbound to register typing/placeholder state.
// Manager uses the registered state on outbound to stop typing and edit placeholders.
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 741fad53e..ff3fa399c 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -89,6 +89,7 @@ type Manager struct {
placeholders sync.Map // "channel:chatID" → placeholderID (string)
typingStops sync.Map // "channel:chatID" → func()
reactionUndos sync.Map // "channel:chatID" → reactionEntry
+ streamActive sync.Map // "channel:chatID" → true (set when streamer.Finalize sent the message)
channelHashes map[string]string // channel name → config hash
}
@@ -157,7 +158,7 @@ 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 edited into a placeholder (skip Send).
+// 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 {
key := name + ":" + msg.ChatID
@@ -175,7 +176,22 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
}
}
- // 3. Try editing placeholder
+ // 3. If a stream already finalized this message, delete the placeholder and skip send
+ if _, loaded := m.streamActive.LoadAndDelete(key); loaded {
+ if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
+ if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
+ // Prefer deleting the placeholder (cleaner UX than editing to same content)
+ if deleter, ok := ch.(MessageDeleter); ok {
+ deleter.DeleteMessage(ctx, msg.ChatID, entry.id) // best effort
+ } else if editor, ok := ch.(MessageEditor); ok {
+ editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content) // fallback
+ }
+ }
+ }
+ return true
+ }
+
+ // 4. Try editing placeholder
if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
if editor, ok := ch.(MessageEditor); ok {
@@ -200,6 +216,9 @@ func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.Medi
channelHashes: make(map[string]string),
}
+ // Register as streaming delegate so the agent loop can obtain streamers
+ messageBus.SetStreamDelegate(m)
+
if err := m.initChannels(&cfg.Channels); err != nil {
return nil, err
}
@@ -210,6 +229,53 @@ func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.Medi
return m, nil
}
+// GetStreamer implements bus.StreamDelegate.
+// It checks if the named channel supports streaming and returns a Streamer.
+func (m *Manager) GetStreamer(ctx context.Context, channelName, chatID string) (bus.Streamer, bool) {
+ m.mu.RLock()
+ ch, exists := m.channels[channelName]
+ m.mu.RUnlock()
+
+ if !exists {
+ return nil, false
+ }
+
+ sc, ok := ch.(StreamingCapable)
+ if !ok {
+ return nil, false
+ }
+
+ streamer, err := sc.BeginStream(ctx, chatID)
+ if err != nil {
+ logger.DebugCF("channels", "Streaming unavailable, falling back to placeholder", map[string]any{
+ "channel": channelName,
+ "error": err.Error(),
+ })
+ return nil, false
+ }
+
+ // Mark streamActive on Finalize so preSend knows to clean up the placeholder
+ key := channelName + ":" + chatID
+ return &finalizeHookStreamer{
+ Streamer: streamer,
+ onFinalize: func() { m.streamActive.Store(key, true) },
+ }, true
+}
+
+// finalizeHookStreamer wraps a Streamer to run a hook on Finalize.
+type finalizeHookStreamer struct {
+ Streamer
+ onFinalize func()
+}
+
+func (s *finalizeHookStreamer) Finalize(ctx context.Context, content string) error {
+ if err := s.Streamer.Finalize(ctx, content); err != nil {
+ return err
+ }
+ s.onFinalize()
+ return nil
+}
+
// initChannel is a helper that looks up a factory by name and creates the channel.
func (m *Manager) initChannel(name, displayName string) {
f, ok := getFactory(name)
diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go
index 2797bdf4a..3eb89c636 100644
--- a/pkg/channels/telegram/telegram.go
+++ b/pkg/channels/telegram/telegram.go
@@ -2,6 +2,8 @@ package telegram
import (
"context"
+ "crypto/rand"
+ "encoding/binary"
"fmt"
"io"
"net/http"
@@ -10,6 +12,7 @@ import (
"regexp"
"strconv"
"strings"
+ "sync"
"time"
"github.com/mymmrac/telego"
@@ -374,6 +377,22 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag
return err
}
+// DeleteMessage implements channels.MessageDeleter.
+func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error {
+ cid, _, err := parseTelegramChatID(chatID)
+ if err != nil {
+ return err
+ }
+ mid, err := strconv.Atoi(messageID)
+ if err != nil {
+ return err
+ }
+ return c.bot.DeleteMessage(ctx, &telego.DeleteMessageParams{
+ ChatID: tu.ID(cid),
+ MessageID: mid,
+ })
+}
+
// SendPlaceholder implements channels.PlaceholderCapable.
// It sends a placeholder message (e.g. "Thinking... 💭") that will later be
// edited to the actual response via EditMessage (channels.MessageEditor).
@@ -847,3 +866,107 @@ func (c *TelegramChannel) stripBotMention(content string) string {
content = re.ReplaceAllString(content, "")
return strings.TrimSpace(content)
}
+
+// BeginStream implements channels.StreamingCapable.
+func (c *TelegramChannel) BeginStream(ctx context.Context, chatID string) (channels.Streamer, error) {
+ if !c.config.Channels.Telegram.Streaming.Enabled {
+ return nil, fmt.Errorf("streaming disabled in config")
+ }
+
+ cid, _, err := parseTelegramChatID(chatID)
+ if err != nil {
+ return nil, err
+ }
+
+ streamCfg := c.config.Channels.Telegram.Streaming
+ return &telegramStreamer{
+ bot: c.bot,
+ chatID: cid,
+ draftID: cryptoRandInt(),
+ throttleInterval: time.Duration(streamCfg.ThrottleSeconds) * time.Second,
+ minGrowth: streamCfg.MinGrowthChars,
+ }, nil
+}
+
+// telegramStreamer streams partial LLM output via Telegram's sendMessageDraft API.
+// On first API error (e.g. bot lacks forum mode), it silently degrades: Update
+// becomes a no-op, while Finalize still delivers the final message.
+type telegramStreamer struct {
+ bot *telego.Bot
+ chatID int64
+ draftID int
+ throttleInterval time.Duration
+ minGrowth int
+ lastLen int
+ lastAt time.Time
+ failed bool
+ mu sync.Mutex
+}
+
+func (s *telegramStreamer) Update(ctx context.Context, content string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if s.failed {
+ return nil
+ }
+
+ // Throttle: skip if not enough time or content has passed
+ now := time.Now()
+ growth := len(content) - s.lastLen
+ if s.lastLen > 0 && now.Sub(s.lastAt) < s.throttleInterval && growth < s.minGrowth {
+ return nil
+ }
+
+ htmlContent := markdownToTelegramHTML(content)
+
+ err := s.bot.SendMessageDraft(ctx, &telego.SendMessageDraftParams{
+ ChatID: s.chatID,
+ DraftID: s.draftID,
+ Text: htmlContent,
+ ParseMode: telego.ModeHTML,
+ })
+ if err != nil {
+ // First error → degrade silently (e.g. no forum mode)
+ logger.WarnCF("telegram", "sendMessageDraft failed, disabling streaming", map[string]any{
+ "error": err.Error(),
+ })
+ s.failed = true
+ return nil // don't propagate — Finalize will still deliver
+ }
+
+ s.lastLen = len(content)
+ s.lastAt = now
+ return nil
+}
+
+func (s *telegramStreamer) Finalize(ctx context.Context, content string) error {
+ htmlContent := markdownToTelegramHTML(content)
+ tgMsg := tu.Message(tu.ID(s.chatID), htmlContent)
+ tgMsg.ParseMode = telego.ModeHTML
+
+ if _, err := s.bot.SendMessage(ctx, tgMsg); err != nil {
+ // Fallback to plain text
+ tgMsg.ParseMode = ""
+ if _, err = s.bot.SendMessage(ctx, tgMsg); err != nil {
+ logger.ErrorCF("telegram", "Finalize failed after HTML and plain-text attempts", map[string]any{
+ "chat_id": s.chatID,
+ "error": err.Error(),
+ "len": len(content),
+ })
+ return fmt.Errorf("telegram finalize: %w", err)
+ }
+ }
+ return nil
+}
+
+func (s *telegramStreamer) Cancel(ctx context.Context) {
+ // Draft auto-expires on Telegram's side; nothing to clean up.
+}
+
+// cryptoRandInt returns a non-zero random int using crypto/rand.
+func cryptoRandInt() int {
+ var b [4]byte
+ _, _ = rand.Read(b[:])
+ return int(binary.BigEndian.Uint32(b[:])) | 1 // ensure non-zero
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index f524e952a..235cb0641 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -318,6 +318,12 @@ type PlaceholderConfig struct {
Text string `json:"text,omitempty"`
}
+type StreamingConfig struct {
+ Enabled bool `json:"enabled,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED"`
+ ThrottleSeconds int `json:"throttle_seconds,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS"`
+ MinGrowthChars int `json:"min_growth_chars,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS"`
+}
+
type WhatsAppConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
@@ -336,6 +342,7 @@ type TelegramConfig struct {
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ Streaming StreamingConfig `json:"streaming,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
}
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index d44c73577..0d2141ae1 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -62,6 +62,7 @@ func DefaultConfig() *Config {
Enabled: true,
Text: "Thinking... 💭",
},
+ Streaming: StreamingConfig{Enabled: true, ThrottleSeconds: 3, MinGrowthChars: 200},
UseMarkdownV2: false,
},
Feishu: FeishuConfig{
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index 4d823630e..803165edb 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -52,6 +52,19 @@ func (p *HTTPProvider) Chat(
return p.delegate.Chat(ctx, messages, tools, model, options)
}
+// ChatStream implements providers.StreamingProvider by delegating to the
+// OpenAI-compatible streaming endpoint (SSE with stream: true).
+func (p *HTTPProvider) ChatStream(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+ onChunk func(accumulated string),
+) (*LLMResponse, error) {
+ return p.delegate.ChatStream(ctx, messages, tools, model, options, onChunk)
+}
+
func (p *HTTPProvider) GetDefaultModel() string {
return ""
}
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index 463db83c9..938e4ea8b 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -1,10 +1,13 @@
package openai_compat
import (
+ "bufio"
"bytes"
"context"
"encoding/json"
"fmt"
+ "io"
+ "log"
"net/http"
"net/url"
"strings"
@@ -85,17 +88,10 @@ func NewProviderWithMaxTokensFieldAndTimeout(
)
}
-func (p *Provider) Chat(
- ctx context.Context,
- messages []Message,
- tools []ToolDefinition,
- model string,
- options map[string]any,
-) (*LLMResponse, error) {
- if p.apiBase == "" {
- return nil, fmt.Errorf("API base not configured")
- }
-
+// buildRequestBody constructs the common request body for Chat and ChatStream.
+func (p *Provider) buildRequestBody(
+ messages []Message, tools []ToolDefinition, model string, options map[string]any,
+) map[string]any {
model = normalizeModel(model, p.apiBase)
requestBody := map[string]any{
@@ -112,10 +108,8 @@ func (p *Provider) Chat(
}
if maxTokens, ok := common.AsInt(options["max_tokens"]); ok {
- // Use configured maxTokensField if specified, otherwise fallback to model-based detection
fieldName := p.maxTokensField
if fieldName == "" {
- // Fallback: detect from model name for backward compatibility
lowerModel := strings.ToLower(model)
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") ||
strings.Contains(lowerModel, "gpt-5") {
@@ -129,7 +123,6 @@ func (p *Provider) Chat(
if temperature, ok := common.AsFloat(options["temperature"]); ok {
lowerModel := strings.ToLower(model)
- // Kimi k2 models only support temperature=1.
if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
requestBody["temperature"] = 1.0
} else {
@@ -139,17 +132,30 @@ func (p *Provider) Chat(
// Prompt caching: pass a stable cache key so OpenAI can bucket requests
// with the same key and reuse prefix KV cache across calls.
- // The key is typically the agent ID — stable per agent, shared across requests.
- // See: https://platform.openai.com/docs/guides/prompt-caching
// Prompt caching is only supported by OpenAI-native endpoints.
- // Non-OpenAI providers (Mistral, Gemini, DeepSeek, etc.) reject unknown
- // fields with 422 errors, so only include it for OpenAI APIs.
+ // Non-OpenAI providers reject unknown fields with 422 errors.
if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
if supportsPromptCacheKey(p.apiBase) {
requestBody["prompt_cache_key"] = cacheKey
}
}
+ return requestBody
+}
+
+func (p *Provider) Chat(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+) (*LLMResponse, error) {
+ if p.apiBase == "" {
+ return nil, fmt.Errorf("API base not configured")
+ }
+
+ requestBody := p.buildRequestBody(messages, tools, model, options)
+
jsonData, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
@@ -178,6 +184,195 @@ func (p *Provider) Chat(
return common.ReadAndParseResponse(resp, p.apiBase)
}
+// ChatStream implements streaming via OpenAI-compatible SSE (stream: true).
+// onChunk receives the accumulated text so far on each text delta.
+func (p *Provider) ChatStream(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+ onChunk func(accumulated string),
+) (*LLMResponse, error) {
+ if p.apiBase == "" {
+ return nil, fmt.Errorf("API base not configured")
+ }
+
+ requestBody := p.buildRequestBody(messages, tools, model, options)
+ requestBody["stream"] = true
+
+ jsonData, err := json.Marshal(requestBody)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", 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("Accept", "text/event-stream")
+ if p.apiKey != "" {
+ req.Header.Set("Authorization", "Bearer "+p.apiKey)
+ }
+
+ // Use a client without Timeout for streaming — the http.Client.Timeout covers
+ // the entire request lifecycle including body reads, which would kill long streams.
+ // Context cancellation still provides the safety net.
+ streamClient := &http.Client{Transport: p.httpClient.Transport}
+ resp, err := streamClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, common.HandleErrorResponse(resp, p.apiBase)
+ }
+
+ return parseStreamResponse(ctx, resp.Body, onChunk)
+}
+
+// parseStreamResponse parses an OpenAI-compatible SSE stream.
+func parseStreamResponse(
+ ctx context.Context,
+ reader io.Reader,
+ onChunk func(accumulated string),
+) (*LLMResponse, error) {
+ var textContent strings.Builder
+ var finishReason string
+ var usage *UsageInfo
+
+ // Tool call assembly: OpenAI streams tool calls as incremental deltas
+ type toolAccum struct {
+ id string
+ name string
+ argsJSON strings.Builder
+ }
+ activeTools := map[int]*toolAccum{}
+
+ scanner := bufio.NewScanner(reader)
+ scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) // 1MB initial, 10MB max
+ for scanner.Scan() {
+ // Check for context cancellation between chunks
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ line := scanner.Text()
+
+ if !strings.HasPrefix(line, "data: ") {
+ continue
+ }
+ data := strings.TrimPrefix(line, "data: ")
+ if data == "[DONE]" {
+ break
+ }
+
+ var chunk struct {
+ Choices []struct {
+ Delta struct {
+ Content string `json:"content"`
+ ToolCalls []struct {
+ Index int `json:"index"`
+ ID string `json:"id"`
+ Function *struct {
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+ } `json:"function"`
+ } `json:"tool_calls"`
+ } `json:"delta"`
+ FinishReason *string `json:"finish_reason"`
+ } `json:"choices"`
+ Usage *UsageInfo `json:"usage"`
+ }
+
+ if err := json.Unmarshal([]byte(data), &chunk); err != nil {
+ continue // skip malformed chunks
+ }
+
+ if chunk.Usage != nil {
+ usage = chunk.Usage
+ }
+
+ if len(chunk.Choices) == 0 {
+ continue
+ }
+
+ choice := chunk.Choices[0]
+
+ // Accumulate text content
+ if choice.Delta.Content != "" {
+ textContent.WriteString(choice.Delta.Content)
+ if onChunk != nil {
+ onChunk(textContent.String())
+ }
+ }
+
+ // Accumulate tool call deltas
+ for _, tc := range choice.Delta.ToolCalls {
+ acc, ok := activeTools[tc.Index]
+ if !ok {
+ acc = &toolAccum{}
+ activeTools[tc.Index] = acc
+ }
+ if tc.ID != "" {
+ acc.id = tc.ID
+ }
+ if tc.Function != nil {
+ if tc.Function.Name != "" {
+ acc.name = tc.Function.Name
+ }
+ if tc.Function.Arguments != "" {
+ acc.argsJSON.WriteString(tc.Function.Arguments)
+ }
+ }
+ }
+
+ if choice.FinishReason != nil {
+ finishReason = *choice.FinishReason
+ }
+ }
+
+ if err := scanner.Err(); err != nil {
+ return nil, fmt.Errorf("streaming read error: %w", err)
+ }
+
+ // Assemble tool calls from accumulated deltas
+ var toolCalls []ToolCall
+ for i := 0; i < len(activeTools); i++ {
+ acc, ok := activeTools[i]
+ if !ok {
+ continue
+ }
+ args := make(map[string]any)
+ raw := acc.argsJSON.String()
+ if raw != "" {
+ if err := json.Unmarshal([]byte(raw), &args); err != nil {
+ log.Printf("openai_compat stream: failed to decode tool call arguments for %q: %v", acc.name, err)
+ args["raw"] = raw
+ }
+ }
+ toolCalls = append(toolCalls, ToolCall{
+ ID: acc.id,
+ Name: acc.name,
+ Arguments: args,
+ })
+ }
+
+ if finishReason == "" {
+ finishReason = "stop"
+ }
+
+ return &LLMResponse{
+ Content: textContent.String(),
+ ToolCalls: toolCalls,
+ FinishReason: finishReason,
+ Usage: usage,
+ }, nil
+}
+
func normalizeModel(model, apiBase string) string {
before, after, ok := strings.Cut(model, "/")
if !ok {
diff --git a/pkg/providers/types.go b/pkg/providers/types.go
index 1f28bc4ad..9a4d126a7 100644
--- a/pkg/providers/types.go
+++ b/pkg/providers/types.go
@@ -37,6 +37,20 @@ type StatefulProvider interface {
Close()
}
+// StreamingProvider is an optional interface for providers that support token streaming.
+// onChunk receives the accumulated text so far (not individual deltas).
+// The returned LLMResponse is the same complete response for compatibility with tool-call handling.
+type StreamingProvider interface {
+ ChatStream(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+ onChunk func(accumulated string),
+ ) (*LLMResponse, error)
+}
+
// ThinkingCapable is an optional interface for providers that support
// extended thinking (e.g. Anthropic). Used by the agent loop to warn
// when thinking_level is configured but the active provider cannot use it.
From 0fe058254cce304c5cacf2bf8ab2efc2dcc2b91c Mon Sep 17 00:00:00 2001
From: liqianjie <3745983@qq.com>
Date: Fri, 20 Mar 2026 22:32:21 +0800
Subject: [PATCH 40/44] fix: add fallback DNS resolver for Android with
multi-DNS support (#1835)
On Android, /etc/resolv.conf does not exist, causing Go's default DNS
resolution to fail. This adds an init() hook that:
1. Detects missing /etc/resolv.conf (Android environment)
2. Configures a custom resolver with PreferGo: true
3. Supports multiple DNS servers via PICOCLAW_DNS_SERVER env var
- Semicolon-separated: "8.8.8.8:53;1.1.1.1:53"
- Single server also works: "8.8.8.8"
- Auto-appends :53 if port omitted
4. Round-robin rotation across configured servers
5. Defaults to Google DNS + Cloudflare DNS
Also patches http.DefaultTransport to use the custom resolver.
---
cmd/picoclaw/dns_noresolv.go | 64 ++++++++++++++++++++++++++++++++++++
1 file changed, 64 insertions(+)
create mode 100644 cmd/picoclaw/dns_noresolv.go
diff --git a/cmd/picoclaw/dns_noresolv.go b/cmd/picoclaw/dns_noresolv.go
new file mode 100644
index 000000000..ba4ae1f4f
--- /dev/null
+++ b/cmd/picoclaw/dns_noresolv.go
@@ -0,0 +1,64 @@
+package main
+
+import (
+ "context"
+ "net"
+ "net/http"
+ "os"
+ "strings"
+ "sync/atomic"
+ "time"
+)
+
+func init() {
+ // 仅在 /etc/resolv.conf 不存在时才覆盖(即 Android 环境)
+ if _, err := os.Stat("/etc/resolv.conf"); err == nil {
+ return
+ }
+
+ // 从环境变量获取 DNS server 列表,多个用 ; 隔开
+ // 例如: PICOCLAW_DNS_SERVER="8.8.8.8:53;1.1.1.1:53;223.5.5.5:53"
+ dnsEnv := os.Getenv("PICOCLAW_DNS_SERVER")
+ if dnsEnv == "" {
+ dnsEnv = "8.8.8.8:53;1.1.1.1:53"
+ }
+
+ var dnsServers []string
+ for _, s := range strings.Split(dnsEnv, ";") {
+ s = strings.TrimSpace(s)
+ if s != "" {
+ // 如果没有带端口号,自动补上 :53
+ if _, _, err := net.SplitHostPort(s); err != nil {
+ s = s + ":53"
+ }
+ dnsServers = append(dnsServers, s)
+ }
+ }
+
+ // 轮询索引,在多个 DNS 服务器之间轮转
+ var idx uint64
+
+ customResolver := &net.Resolver{
+ PreferGo: true,
+ Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
+ d := net.Dialer{Timeout: 5 * time.Second}
+ // Round-robin: 依次尝试不同的 DNS 服务器
+ server := dnsServers[atomic.AddUint64(&idx, 1)%uint64(len(dnsServers))]
+ return d.DialContext(ctx, "udp", server)
+ },
+ }
+
+ // 覆盖全局 DefaultResolver
+ net.DefaultResolver = customResolver
+
+ // 覆盖 http.DefaultTransport 使用自定义 DNS 解析的 DialContext
+ dialer := &net.Dialer{
+ Timeout: 30 * time.Second,
+ KeepAlive: 30 * time.Second,
+ Resolver: customResolver,
+ }
+
+ if tr, ok := http.DefaultTransport.(*http.Transport); ok {
+ tr.DialContext = dialer.DialContext
+ }
+}
From 403ceb39be76f0a316e71f1f7901e871dbef21ec Mon Sep 17 00:00:00 2001
From: BeaconCat <111232138+BeaconCat@users.noreply.github.com>
Date: Fri, 20 Mar 2026 22:37:05 +0800
Subject: [PATCH 41/44] docs: fix inaccuracies, add translations, and expand
channel docs (#1837)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Config field fixes (cross-verified against Go source)
- MaixCam: server_address → host + port
- IRC: use_tls → tls, channels_to_join → channels (all 6 languages)
- WeCom AI Bot: callback port 18791 → 18790
- credential_encryption: base_url → api_base, add required model field,
remove incorrect passphrase-only mode docs
- providers.md: agents.defaults.model → model_name (×4), remove
non-existent session.backlog_limit
- migration guide, troubleshooting: agents.defaults.model → model_name
- ANTIGRAVITY_AUTH: fix file path, Go 1.21 → 1.25, model → model_name
- spawn-tasks: fix truncated file, add Heartbeat introduction
- tools_configuration: add Tavily/SearXNG/GLMSearch, exec allow_remote/
timeout_seconds/custom_allow_patterns, cron allow_command, skills
github/search_cache, clawhub timeout/max_zip_size/max_response_size
- configuration: fix builtin skills path (build-time embedded, not cwd),
HEARTBEAT.md marked auto-generated
## Broken link fixes (15 total)
- chat-apps.md: WeCom/Matrix links with wrong relative paths
- providers.md: migration link with extra docs/ prefix
- hardware-compatibility.md: README links with wrong depth (all 5 langs)
- chat-apps.md: WhatsApp dead links → anchor links (zh/ja)
## Getting-started accuracy
- README (all 6 langs): add picoclaw.io as recommended download,
add missing picoclaw model CLI command
- docker.md: clarify first-run trigger condition (all 6 langs)
- configuration.md: fix builtin skills path description (all 6 langs)
## QQ channel
- Add quick setup via q.qq.com/qqbot/openclaw (one-click bot creation)
- Add manual setup as fallback (all 6 languages)
## Feishu channel
- Update setup flow: WebSocket/SDK mode, no webhook URL needed
- Preserve Lark international domain note (all 6 languages)
## chat-apps.md
- Add Feishu, Slack, IRC, OneBot detail sections (all 6 languages)
- Add MaixCam section to ja/fr/pt-br/vi
- Fix all channel doc links to point to correct language version
## New translations (25 files, 5 docs × 5 languages)
debug.md, credential_encryption.md, hardware-compatibility.md,
ANTIGRAVITY_AUTH.md, ANTIGRAVITY_USAGE.md → zh/ja/fr/pt-br/vi
## Channel docs (6 languages each, 60 new files)
telegram, discord, qq, feishu, maixcam, dingtalk, line, slack, onebot,
wecom/wecom_aibot, wecom/wecom_app, wecom/wecom_bot
Co-authored-by: BeaconCat
---
README.fr.md | 16 +-
README.ja.md | 16 +-
README.md | 799 +++++++++++++++++
README.pt-br.md | 16 +-
README.vi.md | 16 +-
README.zh.md | 16 +-
docs/ANTIGRAVITY_AUTH.md | 8 +-
docs/channels/dingtalk/README.fr.md | 35 +
docs/channels/dingtalk/README.ja.md | 35 +
docs/channels/dingtalk/README.md | 35 +
docs/channels/dingtalk/README.pt-br.md | 35 +
docs/channels/dingtalk/README.vi.md | 35 +
docs/channels/dingtalk/README.zh.md | 2 +
docs/channels/discord/README.fr.md | 39 +
docs/channels/discord/README.ja.md | 39 +
docs/channels/discord/README.md | 39 +
docs/channels/discord/README.pt-br.md | 39 +
docs/channels/discord/README.vi.md | 39 +
docs/channels/discord/README.zh.md | 2 +
docs/channels/feishu/README.fr.md | 48 ++
docs/channels/feishu/README.ja.md | 48 ++
docs/channels/feishu/README.md | 48 ++
docs/channels/feishu/README.pt-br.md | 48 ++
docs/channels/feishu/README.vi.md | 48 ++
docs/channels/feishu/README.zh.md | 21 +-
docs/channels/line/README.fr.md | 40 +
docs/channels/line/README.ja.md | 40 +
docs/channels/line/README.md | 40 +
docs/channels/line/README.pt-br.md | 40 +
docs/channels/line/README.vi.md | 40 +
docs/channels/line/README.zh.md | 2 +
docs/channels/maixcam/README.fr.md | 35 +
docs/channels/maixcam/README.ja.md | 35 +
docs/channels/maixcam/README.md | 35 +
docs/channels/maixcam/README.pt-br.md | 35 +
docs/channels/maixcam/README.vi.md | 35 +
docs/channels/maixcam/README.zh.md | 16 +-
docs/channels/matrix/README.zh.md | 1 +
docs/channels/onebot/README.fr.md | 33 +
docs/channels/onebot/README.ja.md | 33 +
docs/channels/onebot/README.md | 33 +
docs/channels/onebot/README.pt-br.md | 33 +
docs/channels/onebot/README.vi.md | 33 +
docs/channels/onebot/README.zh.md | 2 +
docs/channels/qq/README.fr.md | 54 ++
docs/channels/qq/README.ja.md | 54 ++
docs/channels/qq/README.md | 54 ++
docs/channels/qq/README.pt-br.md | 54 ++
docs/channels/qq/README.vi.md | 54 ++
docs/channels/qq/README.zh.md | 30 +-
docs/channels/slack/README.fr.md | 35 +
docs/channels/slack/README.ja.md | 35 +
docs/channels/slack/README.md | 35 +
docs/channels/slack/README.pt-br.md | 35 +
docs/channels/slack/README.vi.md | 35 +
docs/channels/slack/README.zh.md | 2 +
docs/channels/telegram/README.fr.md | 35 +
docs/channels/telegram/README.ja.md | 35 +
docs/channels/telegram/README.md | 35 +
docs/channels/telegram/README.pt-br.md | 35 +
docs/channels/telegram/README.vi.md | 35 +
docs/channels/telegram/README.zh.md | 2 +
docs/channels/wecom/wecom_aibot/README.fr.md | 118 +++
docs/channels/wecom/wecom_aibot/README.ja.md | 118 +++
docs/channels/wecom/wecom_aibot/README.md | 118 +++
.../wecom/wecom_aibot/README.pt-br.md | 118 +++
docs/channels/wecom/wecom_aibot/README.vi.md | 118 +++
docs/channels/wecom/wecom_aibot/README.zh.md | 5 +-
docs/channels/wecom/wecom_app/README.fr.md | 47 +
docs/channels/wecom/wecom_app/README.ja.md | 47 +
docs/channels/wecom/wecom_app/README.md | 47 +
docs/channels/wecom/wecom_app/README.pt-br.md | 47 +
docs/channels/wecom/wecom_app/README.vi.md | 47 +
docs/channels/wecom/wecom_app/README.zh.md | 2 +
docs/channels/wecom/wecom_bot/README.fr.md | 41 +
docs/channels/wecom/wecom_bot/README.ja.md | 41 +
docs/channels/wecom/wecom_bot/README.md | 41 +
docs/channels/wecom/wecom_bot/README.pt-br.md | 41 +
docs/channels/wecom/wecom_bot/README.vi.md | 41 +
docs/channels/wecom/wecom_bot/README.zh.md | 2 +
docs/chat-apps.md | 209 ++++-
docs/configuration.md | 2 +-
docs/credential_encryption.md | 29 +-
docs/docker.md | 1 +
docs/fr/ANTIGRAVITY_AUTH.md | 809 ++++++++++++++++++
docs/fr/ANTIGRAVITY_USAGE.md | 72 ++
docs/fr/chat-apps.md | 132 +--
docs/fr/configuration.md | 2 +-
docs/fr/credential_encryption.md | 159 ++++
docs/fr/debug.md | 36 +
docs/fr/docker.md | 1 +
docs/fr/hardware-compatibility.md | 152 ++++
docs/fr/providers.md | 13 +-
docs/fr/troubleshooting.md | 4 +-
docs/hardware-compatibility.md | 150 ++++
docs/ja/ANTIGRAVITY_AUTH.md | 809 ++++++++++++++++++
docs/ja/ANTIGRAVITY_USAGE.md | 72 ++
docs/ja/chat-apps.md | 115 ++-
docs/ja/configuration.md | 2 +-
docs/ja/credential_encryption.md | 158 ++++
docs/ja/debug.md | 36 +
docs/ja/docker.md | 1 +
docs/ja/hardware-compatibility.md | 152 ++++
docs/ja/providers.md | 11 +-
docs/ja/troubleshooting.md | 4 +-
docs/migration/model-list-migration.md | 6 +-
docs/providers.md | 13 +-
docs/pt-br/ANTIGRAVITY_AUTH.md | 809 ++++++++++++++++++
docs/pt-br/ANTIGRAVITY_USAGE.md | 72 ++
docs/pt-br/chat-apps.md | 253 +++++-
docs/pt-br/configuration.md | 2 +-
docs/pt-br/credential_encryption.md | 159 ++++
docs/pt-br/debug.md | 36 +
docs/pt-br/docker.md | 1 +
docs/pt-br/hardware-compatibility.md | 152 ++++
docs/pt-br/providers.md | 13 +-
docs/pt-br/troubleshooting.md | 4 +-
docs/spawn-tasks.md | 9 +
docs/tools_configuration.md | 91 +-
docs/troubleshooting.md | 4 +-
docs/vi/ANTIGRAVITY_AUTH.md | 807 +++++++++++++++++
docs/vi/ANTIGRAVITY_USAGE.md | 72 ++
docs/vi/chat-apps.md | 253 +++++-
docs/vi/configuration.md | 2 +-
docs/vi/credential_encryption.md | 159 ++++
docs/vi/debug.md | 36 +
docs/vi/docker.md | 1 +
docs/vi/hardware-compatibility.md | 152 ++++
docs/vi/providers.md | 13 +-
docs/vi/troubleshooting.md | 4 +-
docs/zh/ANTIGRAVITY_AUTH.md | 809 ++++++++++++++++++
docs/zh/ANTIGRAVITY_USAGE.md | 72 ++
docs/zh/chat-apps.md | 91 +-
docs/zh/configuration.md | 2 +-
docs/zh/credential_encryption.md | 158 ++++
docs/zh/debug.md | 36 +
docs/zh/docker.md | 1 +
docs/zh/hardware-compatibility.md | 152 ++++
docs/zh/providers.md | 11 +-
docs/zh/spawn-tasks.md | 10 +-
docs/zh/tools_configuration.md | 95 +-
docs/zh/troubleshooting.md | 4 +-
142 files changed, 11104 insertions(+), 367 deletions(-)
create mode 100644 docs/channels/dingtalk/README.fr.md
create mode 100644 docs/channels/dingtalk/README.ja.md
create mode 100644 docs/channels/dingtalk/README.md
create mode 100644 docs/channels/dingtalk/README.pt-br.md
create mode 100644 docs/channels/dingtalk/README.vi.md
create mode 100644 docs/channels/discord/README.fr.md
create mode 100644 docs/channels/discord/README.ja.md
create mode 100644 docs/channels/discord/README.md
create mode 100644 docs/channels/discord/README.pt-br.md
create mode 100644 docs/channels/discord/README.vi.md
create mode 100644 docs/channels/feishu/README.fr.md
create mode 100644 docs/channels/feishu/README.ja.md
create mode 100644 docs/channels/feishu/README.md
create mode 100644 docs/channels/feishu/README.pt-br.md
create mode 100644 docs/channels/feishu/README.vi.md
create mode 100644 docs/channels/line/README.fr.md
create mode 100644 docs/channels/line/README.ja.md
create mode 100644 docs/channels/line/README.md
create mode 100644 docs/channels/line/README.pt-br.md
create mode 100644 docs/channels/line/README.vi.md
create mode 100644 docs/channels/maixcam/README.fr.md
create mode 100644 docs/channels/maixcam/README.ja.md
create mode 100644 docs/channels/maixcam/README.md
create mode 100644 docs/channels/maixcam/README.pt-br.md
create mode 100644 docs/channels/maixcam/README.vi.md
create mode 100644 docs/channels/onebot/README.fr.md
create mode 100644 docs/channels/onebot/README.ja.md
create mode 100644 docs/channels/onebot/README.md
create mode 100644 docs/channels/onebot/README.pt-br.md
create mode 100644 docs/channels/onebot/README.vi.md
create mode 100644 docs/channels/qq/README.fr.md
create mode 100644 docs/channels/qq/README.ja.md
create mode 100644 docs/channels/qq/README.md
create mode 100644 docs/channels/qq/README.pt-br.md
create mode 100644 docs/channels/qq/README.vi.md
create mode 100644 docs/channels/slack/README.fr.md
create mode 100644 docs/channels/slack/README.ja.md
create mode 100644 docs/channels/slack/README.md
create mode 100644 docs/channels/slack/README.pt-br.md
create mode 100644 docs/channels/slack/README.vi.md
create mode 100644 docs/channels/telegram/README.fr.md
create mode 100644 docs/channels/telegram/README.ja.md
create mode 100644 docs/channels/telegram/README.md
create mode 100644 docs/channels/telegram/README.pt-br.md
create mode 100644 docs/channels/telegram/README.vi.md
create mode 100644 docs/channels/wecom/wecom_aibot/README.fr.md
create mode 100644 docs/channels/wecom/wecom_aibot/README.ja.md
create mode 100644 docs/channels/wecom/wecom_aibot/README.md
create mode 100644 docs/channels/wecom/wecom_aibot/README.pt-br.md
create mode 100644 docs/channels/wecom/wecom_aibot/README.vi.md
create mode 100644 docs/channels/wecom/wecom_app/README.fr.md
create mode 100644 docs/channels/wecom/wecom_app/README.ja.md
create mode 100644 docs/channels/wecom/wecom_app/README.md
create mode 100644 docs/channels/wecom/wecom_app/README.pt-br.md
create mode 100644 docs/channels/wecom/wecom_app/README.vi.md
create mode 100644 docs/channels/wecom/wecom_bot/README.fr.md
create mode 100644 docs/channels/wecom/wecom_bot/README.ja.md
create mode 100644 docs/channels/wecom/wecom_bot/README.md
create mode 100644 docs/channels/wecom/wecom_bot/README.pt-br.md
create mode 100644 docs/channels/wecom/wecom_bot/README.vi.md
create mode 100644 docs/fr/ANTIGRAVITY_AUTH.md
create mode 100644 docs/fr/ANTIGRAVITY_USAGE.md
create mode 100644 docs/fr/credential_encryption.md
create mode 100644 docs/fr/debug.md
create mode 100644 docs/fr/hardware-compatibility.md
create mode 100644 docs/hardware-compatibility.md
create mode 100644 docs/ja/ANTIGRAVITY_AUTH.md
create mode 100644 docs/ja/ANTIGRAVITY_USAGE.md
create mode 100644 docs/ja/credential_encryption.md
create mode 100644 docs/ja/debug.md
create mode 100644 docs/ja/hardware-compatibility.md
create mode 100644 docs/pt-br/ANTIGRAVITY_AUTH.md
create mode 100644 docs/pt-br/ANTIGRAVITY_USAGE.md
create mode 100644 docs/pt-br/credential_encryption.md
create mode 100644 docs/pt-br/debug.md
create mode 100644 docs/pt-br/hardware-compatibility.md
create mode 100644 docs/vi/ANTIGRAVITY_AUTH.md
create mode 100644 docs/vi/ANTIGRAVITY_USAGE.md
create mode 100644 docs/vi/credential_encryption.md
create mode 100644 docs/vi/debug.md
create mode 100644 docs/vi/hardware-compatibility.md
create mode 100644 docs/zh/ANTIGRAVITY_AUTH.md
create mode 100644 docs/zh/ANTIGRAVITY_USAGE.md
create mode 100644 docs/zh/credential_encryption.md
create mode 100644 docs/zh/debug.md
create mode 100644 docs/zh/hardware-compatibility.md
diff --git a/README.fr.md b/README.fr.md
index bf49ed90a..cbaffc2d1 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -105,6 +105,8 @@ _*Les versions récentes peuvent utiliser 10–20 Mo en raison des fusions rapid
+> 📋 **[Liste de Compatibilité Matérielle](docs/hardware-compatibility.md)** — Voir toutes les cartes testées, du RISC-V à $5 au Raspberry Pi en passant par les téléphones Android. Votre carte n'est pas listée ? Soumettez une PR !
+
## 🦾 Démonstration
### 🛠️ Flux de Travail Standard de l'Assistant
@@ -139,7 +141,7 @@ Donnez une seconde vie à votre téléphone d'il y a dix ans ! Transformez-le en
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
pkg install proot
-termux-chroot ./picoclaw onboard
+termux-chroot ./picoclaw onboard # chroot fournit une disposition standard du système de fichiers Linux
```
Puis suivez les instructions de la section « Démarrage Rapide » pour terminer la configuration !
@@ -160,11 +162,15 @@ PicoClaw peut être déployé sur pratiquement n'importe quel appareil Linux !
## 📦 Installation
-### Installer avec un binaire précompilé
+### Télécharger depuis picoclaw.io (Recommandé)
-Téléchargez le binaire pour votre plateforme depuis la page des [Releases](https://github.com/sipeed/picoclaw/releases).
+Visitez **[picoclaw.io](https://picoclaw.io)** — le site officiel détecte automatiquement votre plateforme et propose un téléchargement en un clic. Pas besoin de choisir manuellement une architecture.
-### Installer depuis les sources (dernières fonctionnalités, recommandé pour le développement)
+### Télécharger le binaire précompilé
+
+Vous pouvez aussi télécharger le binaire pour votre plateforme depuis la page [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
+
+### Compiler depuis les sources (pour le développement)
```bash
git clone https://github.com/sipeed/picoclaw.git
@@ -200,6 +206,7 @@ Pour des guides détaillés, consultez la documentation ci-dessous. Ce README ne
| 🔄 [Spawn & Tâches Asynchrones](docs/fr/spawn-tasks.md) | Tâches rapides, tâches longues avec spawn, orchestration asynchrone de sous-agents |
| 🐛 [Dépannage](docs/fr/troubleshooting.md) | Problèmes courants et solutions |
| 🔧 [Configuration des Outils](docs/fr/tools_configuration.md) | Activation/désactivation par outil, politiques exec |
+| 📋 [Compatibilité Matérielle](docs/hardware-compatibility.md) | Cartes testées, exigences minimales, comment ajouter votre carte |
##
Rejoignez le Réseau Social d'Agents
@@ -225,6 +232,7 @@ Connectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul mes
| `picoclaw skills install` | Installer une compétence |
| `picoclaw migrate` | Migrer les données des anciennes versions |
| `picoclaw auth login` | S'authentifier auprès des fournisseurs |
+| `picoclaw model` | Voir ou changer le modèle par défaut |
### Tâches Planifiées / Rappels
diff --git a/README.ja.md b/README.ja.md
index 3c017aacd..e5a927505 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -105,6 +105,8 @@ _*最近のバージョンでは急速な機能マージにより 10〜20MB に
+> 📋 **[ハードウェア互換性リスト](docs/hardware-compatibility.md)** — テスト済みの全ボード一覧($5 RISC-V から Raspberry Pi、Android スマートフォンまで)。お使いのボードが未掲載?PR を送ってください!
+
## 🦾 デモンストレーション
### 🛠️ スタンダードアシスタントワークフロー
@@ -139,7 +141,7 @@ _*最近のバージョンでは急速な機能マージにより 10〜20MB に
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
pkg install proot
-termux-chroot ./picoclaw onboard
+termux-chroot ./picoclaw onboard # chroot で標準的な Linux ファイルシステムレイアウトを提供
```
その後「クイックスタート」セクションの手順に従って設定を完了してください!
@@ -160,11 +162,15 @@ PicoClaw はほぼすべての Linux デバイスにデプロイできます!
## 📦 インストール
-### コンパイル済みバイナリでインストール
+### picoclaw.io からダウンロード(推奨)
-[リリースページ](https://github.com/sipeed/picoclaw/releases) からお使いのプラットフォーム用のバイナリをダウンロードしてください。
+**[picoclaw.io](https://picoclaw.io)** にアクセス — 公式サイトがプラットフォームを自動検出し、ワンクリックでダウンロードできます。アーキテクチャを手動で選ぶ必要はありません。
-### ソースからインストール(最新機能、開発向け推奨)
+### プリコンパイル済みバイナリをダウンロード
+
+または、[GitHub Releases](https://github.com/sipeed/picoclaw/releases) ページからプラットフォームに合ったバイナリをダウンロードしてください。
+
+### ソースからビルド(開発用)
```bash
git clone https://github.com/sipeed/picoclaw.git
@@ -200,6 +206,7 @@ make install
| 🔄 [Spawn & 非同期タスク](docs/ja/spawn-tasks.md) | クイックタスク、spawn による長時間タスク、非同期サブエージェントオーケストレーション |
| 🐛 [トラブルシューティング](docs/ja/troubleshooting.md) | よくある問題と解決策 |
| 🔧 [ツール設定](docs/ja/tools_configuration.md) | ツールごとの有効/無効、exec ポリシー |
+| 📋 [ハードウェア互換性](docs/hardware-compatibility.md) | テスト済みボード、最小要件、ボードの追加方法 |
##
エージェントソーシャルネットワークに参加
@@ -225,6 +232,7 @@ CLI または統合チャットアプリからメッセージを 1 つ送るだ
| `picoclaw skills install` | スキルをインストール |
| `picoclaw migrate` | 旧バージョンからデータを移行 |
| `picoclaw auth login` | プロバイダーへの認証 |
+| `picoclaw model` | デフォルトモデルの表示・切替 |
### スケジュールタスク / リマインダー
diff --git a/README.md b/README.md
index d9785f200..652792d83 100644
--- a/README.md
+++ b/README.md
@@ -105,6 +105,757 @@ _*Recent versions may use 10–20MB due to rapid feature merges. Resource optimi
+> 📋 **[Hardware Compatibility List](docs/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR!
+
+## 🦾 Demonstration
+
+### 🛠️ Standard Assistant Workflows
+
+
+
+ 🧩 Full-Stack Engineer |
+ 🗂️ Logging & Planning Management |
+ 🔎 Web Search & Learning |
+
+
+ 
|
+ 
|
+ 
|
+
+
+ | Develop • Deploy • Scale |
+ Schedule • Automate • Memory |
+ Discovery • Insights • Trends |
+
+
+
+### 📱 Run on old Android Phones
+
+Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
+
+1. **Install [Termux](https://github.com/termux/termux-app)** (Download from [GitHub Releases](https://github.com/termux/termux-app/releases), or search in F-Droid / Google Play).
+2. **Execute cmds**
+
+```bash
+# Download the latest release from https://github.com/sipeed/picoclaw/releases
+wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
+tar xzf picoclaw_Linux_arm64.tar.gz
+pkg install proot
+termux-chroot ./picoclaw onboard # chroot provides a standard Linux filesystem layout
+```
+
+And then follow the instructions in the "Quick Start" section to complete the configuration!
+
+
+
+### 🐜 Innovative Low-Footprint Deploy
+
+PicoClaw can be deployed on almost any Linux device!
+
+- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant
+- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) for Automated Server Maintenance
+- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) for Smart Monitoring
+
+
+
+🌟 More Deployment Cases Await!
+
+## 📦 Install
+
+### Download from picoclaw.io (Recommended)
+
+Visit **[picoclaw.io](https://picoclaw.io)** — the official website auto-detects your platform and provides one-click download. No need to manually pick an architecture.
+
+### Download precompiled binary
+
+Alternatively, download the binary for your platform from the [GitHub Releases](https://github.com/sipeed/picoclaw/releases) page.
+
+### Build from source (for development)
+
+```bash
+git clone https://github.com/sipeed/picoclaw.git
+
+cd picoclaw
+make deps
+
+# Build, no need to install
+make build
+
+# Build for multiple platforms
+make build-all
+
+# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
+make build-pi-zero
+
+# Build And Install
+make install
+```
+
+**Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Or run `make build-pi-zero` to build both.
+
+## 📚 Documentation
+
+For detailed guides, see the docs below. The README covers quick start only.
+
+```bash
+# 1. Clone this repo
+git clone https://github.com/sipeed/picoclaw.git
+cd picoclaw
+
+# 2. First run — auto-generates docker/data/config.json then exits
+docker compose -f docker/docker-compose.yml --profile gateway up
+# The container prints "First-run setup complete." and stops.
+
+# 3. Set your API keys
+vim docker/data/config.json # Set provider API keys, bot tokens, etc.
+
+# 4. Start
+docker compose -f docker/docker-compose.yml --profile gateway up -d
+```
+
+> [!TIP]
+> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
+
+```bash
+# 5. Check logs
+docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
+
+# 6. Stop
+docker compose -f docker/docker-compose.yml --profile gateway down
+```
+
+### Launcher Mode (Web Console)
+
+The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat.
+
+```bash
+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.
+
+### Agent Mode (One-shot)
+
+```bash
+# Ask a question
+docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
+
+# Interactive mode
+docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
+```
+
+### Update
+
+```bash
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile gateway up -d
+```
+
+### 🚀 Quick Start
+
+> [!TIP]
+> Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month).
+
+**1. Initialize**
+
+```bash
+picoclaw onboard
+```
+
+**2. Configure** (`~/.picoclaw/config.json`)
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model_name": "gpt-5.4",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "ark-code-latest",
+ "model": "volcengine/ark-code-latest",
+ "api_key": "sk-your-api-key"
+ },
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_key": "your-api-key",
+ "request_timeout": 300
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "your-anthropic-key"
+ }
+ ],
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "tavily": {
+ "enabled": false,
+ "api_key": "YOUR_TAVILY_API_KEY",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ },
+ "perplexity": {
+ "enabled": false,
+ "api_key": "YOUR_PERPLEXITY_API_KEY",
+ "max_results": 5
+ },
+ "searxng": {
+ "enabled": false,
+ "base_url": "http://your-searxng-instance:8888",
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
+> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s).
+
+**3. Get API Keys**
+
+* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
+* **Web Search** (optional):
+ * [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month)
+ * [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface
+ * [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed)
+ * [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month)
+ * DuckDuckGo - Built-in fallback (no API key required)
+
+> **Note**: See `config.example.json` for a complete configuration template.
+
+**4. Chat**
+
+```bash
+picoclaw agent -m "What is 2+2?"
+```
+
+That's it! You have a working AI assistant in 2 minutes.
+
+---
+
+## 💬 Chat Apps
+
+Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom
+
+> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
+
+| Channel | Setup |
+| ------------ | ---------------------------------- |
+| **Telegram** | Easy (just a token) |
+| **Discord** | Easy (bot token + intents) |
+| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
+| **Matrix** | Medium (homeserver + bot access token) |
+| **QQ** | Easy (AppID + AppSecret) |
+| **DingTalk** | Medium (app credentials) |
+| **LINE** | Medium (credentials + webhook URL) |
+| **WeCom AI Bot** | Medium (Token + AES key) |
+
+
+Telegram (Recommended)
+
+**1. Create a bot**
+
+* Open Telegram, search `@BotFather`
+* Send `/newbot`, follow prompts
+* Copy the token
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+> Get your user ID from `@userinfobot` on Telegram.
+
+**3. Run**
+
+```bash
+picoclaw gateway
+```
+
+**4. Telegram command menu (auto-registered at startup)**
+
+PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync.
+Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor.
+
+If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
+
+
+
+
+Discord
+
+**1. Create a bot**
+
+* Go to
+* Create an application → Bot → Add Bot
+* Copy the bot token
+
+**2. Enable intents**
+
+* In the Bot settings, enable **MESSAGE CONTENT INTENT**
+* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
+
+**3. Get your User ID**
+* Discord Settings → Advanced → enable **Developer Mode**
+* Right-click your avatar → **Copy User ID**
+
+**4. Configure**
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+**5. Invite the bot**
+
+* OAuth2 → URL Generator
+* Scopes: `bot`
+* Bot Permissions: `Send Messages`, `Read Message History`
+* Open the generated invite URL and add the bot to your server
+
+**Optional: Group trigger mode**
+
+By default the bot responds to all messages in a server channel. To restrict responses to @-mentions only, add:
+
+```json
+{
+ "channels": {
+ "discord": {
+ "group_trigger": { "mention_only": true }
+ }
+ }
+}
+```
+
+You can also trigger by keyword prefixes (e.g. `!bot`):
+
+```json
+{
+ "channels": {
+ "discord": {
+ "group_trigger": { "prefixes": ["!bot"] }
+ }
+ }
+}
+```
+
+**6. Run**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+WhatsApp (native via whatsmeow)
+
+PicoClaw can connect to WhatsApp in two ways:
+
+- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`).
+- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false.
+
+**Configure (native)**
+
+```json
+{
+ "channels": {
+ "whatsapp": {
+ "enabled": true,
+ "use_native": true,
+ "session_store_path": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+If `session_store_path` is empty, the session is stored in `<workspace>/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices.
+
+
+
+
+QQ
+
+**1. Create a bot**
+
+- Go to [QQ Open Platform](https://q.qq.com/#)
+- Create an application → Get **AppID** and **AppSecret**
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access.
+
+**3. Run**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+DingTalk
+
+**1. Create a bot**
+
+* Go to [Open Platform](https://open.dingtalk.com/)
+* Create an internal app
+* Copy Client ID and Client Secret
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access.
+
+**3. Run**
+
+```bash
+picoclaw gateway
+```
+
+
+
+Matrix
+
+**1. Prepare bot account**
+
+* Use your preferred homeserver (e.g. `https://matrix.org` or self-hosted)
+* Create a bot user and obtain its access token
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "matrix": {
+ "enabled": true,
+ "homeserver": "https://matrix.org",
+ "user_id": "@your-bot:matrix.org",
+ "access_token": "YOUR_MATRIX_ACCESS_TOKEN",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**3. Run**
+
+```bash
+picoclaw gateway
+```
+
+For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](docs/channels/matrix/README.md).
+
+
+
+
+LINE
+
+**1. Create a LINE Official Account**
+
+- Go to [LINE Developers Console](https://developers.line.biz/)
+- Create a provider → Create a Messaging API channel
+- Copy **Channel Secret** and **Channel Access Token**
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "YOUR_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> LINE webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).
+
+**3. Set up Webhook URL**
+
+LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel:
+
+```bash
+# Example with ngrok (gateway default port is 18790)
+ngrok http 18790
+```
+
+Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**.
+
+**4. Run**
+
+```bash
+picoclaw gateway
+```
+
+> In group chats, the bot responds only when @mentioned. Replies quote the original message.
+
+
+
+
+WeCom (企业微信)
+
+PicoClaw supports three types of WeCom integration:
+
+**Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats
+**Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only
+**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat
+
+See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions.
+
+**Quick Setup - WeCom AI Bot:**
+
+**1. Create an AI Bot**
+
+* Go to WeCom Admin Console → AI Bot
+* Create a new AI Bot → Set name, avatar, etc.
+* Copy **Bot ID** and **Secret**
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "wecom_aibot": {
+ "enabled": true,
+ "bot_id": "YOUR_BOT_ID",
+ "secret": "YOUR_SECRET",
+ "allow_from": [],
+ "welcome_message": "Hello! How can I help you?"
+ }
+ }
+}
+```
+
+**3. Run**
+
+```bash
+picoclaw gateway
+```
+
+> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.
+
+
+
+##
Join the Agent Social Network
+
+Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.
+
+**Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)**
+
+## 🖥️ CLI Reference
+
+| Command | Description |
+| ------------------------- | ----------------------------- |
+| `picoclaw onboard` | Initialize config & workspace |
+| `picoclaw agent -m "..."` | Chat with the agent |
+| `picoclaw agent` | Interactive chat mode |
+| `picoclaw gateway` | Start the gateway |
+| `picoclaw status` | Show status |
+| `picoclaw version` | Show version info |
+| `picoclaw cron list` | List all scheduled jobs |
+| `picoclaw cron add ...` | Add a scheduled job |
+| `picoclaw cron disable` | Disable a scheduled job |
+| `picoclaw cron remove` | Remove a scheduled job |
+| `picoclaw skills list` | List installed skills |
+| `picoclaw skills install` | Install a skill |
+| `picoclaw migrate` | Migrate data from older versions |
+| `picoclaw auth login` | Authenticate with providers |
+
+### Scheduled Tasks / Reminders
+
+PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool:
+
+* **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min
+* **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours
+* **Cron expressions**: "Remind me at 9am daily" → uses cron expression
+
+## 🤝 Contribute & Roadmap
+
+PRs welcome! The codebase is intentionally small and readable. 🤗
+
+See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md).
+
+Developer group building, join after your first merged PR!
+
+User Groups:
+
+discord:
+
+
center">
+
+
+ PicoClaw: Ultra-Efficient AI Assistant in Go
+
+ $10 Hardware · <10MB RAM · <1s Boot · 皮皮虾,我们走!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **English**
+
+
+
+---
+
+> **PicoClaw** is an independent open-source project initiated by [Sipeed](https://sipeed.com). It is written entirely in **Go** — not a fork of OpenClaw, NanoBot, or any other project.
+
+🦐 PicoClaw is an ultra-lightweight personal AI Assistant inspired by [NanoBot](https://github.com/HKUDS/nanobot), refactored from the ground up in Go through a self-bootstrapping process, where the AI agent itself drove the entire architectural migration and code optimization.
+
+⚡️ Runs on $10 hardware with <10MB RAM: That's 99% less memory than OpenClaw and 98% cheaper than a Mac mini!
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+> [!CAUTION]
+> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
+>
+> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
+>
+> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
+> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
+> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
+> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
+
+## 📢 News
+
+2026-03-17 🚀 **v0.2.3 Released!** System tray UI (Windows & Linux), sub-agent status tracking (`spawn_status`), experimental gateway hot-reload, cron security gates, and 2 security fixes. PicoClaw now at **25K ⭐**!
+
+2026-03-09 🎉 **v0.2.1 — Biggest update yet!** MCP protocol support, 4 new channels (Matrix/IRC/WeCom/Discord Proxy), 3 new providers (Kimi/Minimax/Avian), vision pipeline, JSONL memory store, and model routing.
+
+2026-02-28 📦 **v0.2.0** released with Docker Compose support and Web UI launcher.
+
+2026-02-26 🎉 PicoClaw hit **20K stars** in just 17 days! Channel auto-orchestration and capability interfaces landed.
+
+
+Older news...
+
+2026-02-16 🎉 PicoClaw hit 12K stars in one week! Community maintainer roles and [roadmap](ROADMAP.md) officially posted.
+
+2026-02-13 🎉 PicoClaw hit 5000 stars in 4 days! Project Roadmap and Developer Group setup underway.
+
+2026-02-09 🎉 **PicoClaw Launched!** Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
+
+
+
+## ✨ Features
+
+🪶 **Ultra-Lightweight**: <10MB Memory footprint — 99% smaller than OpenClaw core functionality.*
+
+💰 **Minimal Cost**: Efficient enough to run on $10 Hardware — 98% cheaper than a Mac mini.
+
+⚡️ **Lightning Fast**: 400X Faster startup time, boot in <1 second even on 0.6GHz single core.
+
+🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, MIPS, and x86, One-click to Go!
+
+🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
+
+🔌 **MCP Support**: Native [Model Context Protocol](https://modelcontextprotocol.io/) integration — connect any MCP server to extend agent capabilities.
+
+👁️ **Vision Pipeline**: Send images and files directly to the agent — automatic base64 encoding for multimodal LLMs.
+
+🧠 **Smart Routing**: Rule-based model routing — simple queries go to lightweight models, saving API costs.
+
+_*Recent versions may use 10–20MB due to rapid feature merges. Resource optimization is planned. Startup comparison based on 0.8GHz single-core benchmarks (see table below)._
+
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
+| **Language** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB*** |
+| **Startup**(0.8GHz core) | >500s | >30s | **<1s** |
+| **Cost** | Mac Mini $599 | Most Linux SBC ~$50 | **Any Linux Board****As low as $10** |
+
+
+
+> 📋 **[Hardware Compatibility List](docs/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR!
+
## 🦾 Demonstration
### 🛠️ Standard Assistant Workflows
@@ -741,4 +1492,52 @@ User Groups:
discord:
+
+
+##
Join the Agent Social Network
+
+Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.
+
+**Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)**
+
+## 🖥️ CLI Reference
+
+| Command | Description |
+| ------------------------- | ----------------------------- |
+| `picoclaw onboard` | Initialize config & workspace |
+| `picoclaw agent -m "..."` | Chat with the agent |
+| `picoclaw agent` | Interactive chat mode |
+| `picoclaw gateway` | Start the gateway |
+| `picoclaw status` | Show status |
+| `picoclaw version` | Show version info |
+| `picoclaw cron list` | List all scheduled jobs |
+| `picoclaw cron add ...` | Add a scheduled job |
+| `picoclaw cron disable` | Disable a scheduled job |
+| `picoclaw cron remove` | Remove a scheduled job |
+| `picoclaw skills list` | List installed skills |
+| `picoclaw skills install` | Install a skill |
+| `picoclaw migrate` | Migrate data from older versions |
+| `picoclaw auth login` | Authenticate with providers |
+| `picoclaw model` | View or switch the default model |
+
+### Scheduled Tasks / Reminders
+
+PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool:
+
+* **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min
+* **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours
+* **Cron expressions**: "Remind me at 9am daily" → uses cron expression
+
+## 🤝 Contribute & Roadmap
+
+PRs welcome! The codebase is intentionally small and readable. 🤗
+
+See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md).
+
+Developer group building, join after your first merged PR!
+
+User Groups:
+
+discord:
+
diff --git a/README.pt-br.md b/README.pt-br.md
index 928e4778c..c1df570a5 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -105,6 +105,8 @@ _*Versões recentes podem usar 10–20MB devido a merges rápidos de funcionalid
+> 📋 **[Lista de Compatibilidade de Hardware](docs/hardware-compatibility.md)** — Veja todas as placas testadas, de RISC-V de $5 a Raspberry Pi e telefones Android. Sua placa não está listada? Envie um PR!
+
## 🦾 Demonstração
### 🛠️ Fluxos de Trabalho Padrão do Assistente
@@ -139,7 +141,7 @@ Dê uma segunda vida ao seu celular de dez anos atrás! Transforme-o em um assis
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
pkg install proot
-termux-chroot ./picoclaw onboard
+termux-chroot ./picoclaw onboard # chroot fornece um layout padrão do sistema de arquivos Linux
```
Depois siga as instruções na seção "Início Rápido" para completar a configuração!
@@ -160,11 +162,15 @@ O PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux!
## 📦 Instalação
-### Instalar com binário pré-compilado
+### Baixar de picoclaw.io (Recomendado)
-Baixe o binário para sua plataforma na página de [Releases](https://github.com/sipeed/picoclaw/releases).
+Visite **[picoclaw.io](https://picoclaw.io)** — o site oficial detecta automaticamente sua plataforma e oferece download com um clique. Sem necessidade de escolher manualmente a arquitetura.
-### Instalar a partir do código-fonte (funcionalidades mais recentes, recomendado para desenvolvimento)
+### Baixar binário pré-compilado
+
+Alternativamente, baixe o binário para sua plataforma na página de [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
+
+### Compilar a partir do código-fonte (para desenvolvimento)
```bash
git clone https://github.com/sipeed/picoclaw.git
@@ -200,6 +206,7 @@ Para guias detalhados, consulte a documentação abaixo. Este README cobre apena
| 🔄 [Spawn & Tarefas Assíncronas](docs/pt-br/spawn-tasks.md) | Tarefas rápidas, tarefas longas com spawn, orquestração assíncrona de sub-agentes |
| 🐛 [Solução de Problemas](docs/pt-br/troubleshooting.md) | Problemas comuns e soluções |
| 🔧 [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) | Habilitar/desabilitar por ferramenta, políticas de execução |
+| 📋 [Compatibilidade de Hardware](docs/hardware-compatibility.md) | Placas testadas, requisitos mínimos, como adicionar sua placa |
##
Junte-se à Rede Social de Agentes
@@ -225,6 +232,7 @@ Conecte o PicoClaw à Rede Social de Agentes simplesmente enviando uma única me
| `picoclaw skills install` | Instalar uma skill |
| `picoclaw migrate` | Migrar dados de versões anteriores |
| `picoclaw auth login` | Autenticar com provedores |
+| `picoclaw model` | Ver ou trocar o modelo padrão |
### Tarefas Agendadas / Lembretes
diff --git a/README.vi.md b/README.vi.md
index c7ad6b4be..cd65ac526 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -105,6 +105,8 @@ _*Các phiên bản gần đây có thể sử dụng 10–20MB do merge tính n
+> 📋 **[Danh Sách Tương Thích Phần Cứng](docs/hardware-compatibility.md)** — Xem tất cả các board đã được kiểm tra, từ RISC-V $5 đến Raspberry Pi và điện thoại Android. Board của bạn chưa có? Gửi PR!
+
## 🦾 Demo
### 🛠️ Quy trình trợ lý tiêu chuẩn
@@ -139,7 +141,7 @@ Hãy cho chiếc điện thoại cũ một cuộc sống mới! Biến nó thàn
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
pkg install proot
-termux-chroot ./picoclaw onboard
+termux-chroot ./picoclaw onboard # chroot cung cấp bố cục hệ thống tệp Linux tiêu chuẩn
```
Sau đó làm theo hướng dẫn trong phần "Bắt đầu nhanh" để hoàn tất cấu hình!
@@ -160,11 +162,15 @@ PicoClaw có thể triển khai trên hầu hết mọi thiết bị Linux!
## 📦 Cài đặt
-### Cài đặt bằng binary biên dịch sẵn
+### Tải từ picoclaw.io (Khuyến nghị)
-Tải file binary cho nền tảng của bạn từ [trang Releases](https://github.com/sipeed/picoclaw/releases).
+Truy cập **[picoclaw.io](https://picoclaw.io)** — trang web chính thức tự động phát hiện nền tảng của bạn và cung cấp tải xuống một cú nhấp. Không cần chọn kiến trúc thủ công.
-### Cài đặt từ mã nguồn (có tính năng mới nhất, khuyên dùng cho phát triển)
+### Tải binary đã biên dịch sẵn
+
+Hoặc tải binary cho nền tảng của bạn từ trang [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
+
+### Biên dịch từ mã nguồn (cho phát triển)
```bash
git clone https://github.com/sipeed/picoclaw.git
@@ -200,6 +206,7 @@ make install
| 🔄 [Spawn & Tác vụ bất đồng bộ](docs/vi/spawn-tasks.md) | Tác vụ nhanh, tác vụ dài với spawn, điều phối sub-agent bất đồng bộ |
| 🐛 [Xử lý sự cố](docs/vi/troubleshooting.md) | Các vấn đề thường gặp và giải pháp |
| 🔧 [Cấu hình Công cụ](docs/vi/tools_configuration.md) | Bật/tắt từng công cụ, chính sách thực thi |
+| 📋 [Tương Thích Phần Cứng](docs/hardware-compatibility.md) | Các board đã kiểm tra, yêu cầu tối thiểu, cách thêm board |
##
Tham gia Mạng xã hội Agent
@@ -225,6 +232,7 @@ Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một
| `picoclaw skills install` | Cài đặt một skill |
| `picoclaw migrate` | Di chuyển dữ liệu từ phiên bản cũ |
| `picoclaw auth login` | Xác thực với nhà cung cấp |
+| `picoclaw model` | Xem hoặc chuyển đổi model mặc định |
### Tác vụ định kỳ / Nhắc nhở
diff --git a/README.zh.md b/README.zh.md
index 7bf936709..db34f57da 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -104,6 +104,8 @@ _*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入
+> 📋 **[硬件兼容列表](docs/hardware-compatibility.md)** — 查看所有已测试的板卡,从 $5 RISC-V 到树莓派到安卓手机。你的板卡没在列表中?欢迎提交 PR!
+
## 🦾 演示
### 🛠️ 标准助手工作流
@@ -138,7 +140,7 @@ PicoClaw 可以将你 10 年前的老旧手机废物利用,变身成为你的
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
pkg install proot
-termux-chroot ./picoclaw onboard
+termux-chroot ./picoclaw onboard # chroot 提供标准 Linux 文件系统布局
```
然后跟随下面的"快速开始"章节继续配置 PicoClaw 即可使用!
@@ -159,11 +161,15 @@ PicoClaw 几乎可以部署在任何 Linux 设备上!
## 📦 安装
-### 使用预编译二进制文件安装
+### 从 picoclaw.io 下载(推荐)
-从 [Release 页面](https://github.com/sipeed/picoclaw/releases) 下载适用于您平台的二进制文件。
+访问 **[picoclaw.io](https://picoclaw.io)** — 官网自动检测你的平台,提供一键下载,无需手动选择架构。
-### 从源码安装(获取最新特性,开发推荐)
+### 下载预编译二进制文件
+
+也可以从 [GitHub Releases](https://github.com/sipeed/picoclaw/releases) 页面手动下载对应平台的二进制文件。
+
+### 从源码构建(开发用)
```bash
git clone https://github.com/sipeed/picoclaw.git
@@ -199,6 +205,7 @@ make install
| 🔄 [异步任务与 Spawn](docs/zh/spawn-tasks.md) | 快速任务、长任务与 Spawn、异步子 Agent 编排 |
| 🐛 [疑难解答](docs/zh/troubleshooting.md) | 常见问题与解决方案 |
| 🔧 [工具配置](docs/zh/tools_configuration.md) | 工具启用/禁用、执行策略 |
+| 📋 [硬件兼容列表](docs/hardware-compatibility.md) | 已测试板卡、最低要求、如何添加你的板卡 |
##
加入 Agent 社交网络
@@ -224,6 +231,7 @@ make install
| `picoclaw skills install` | 安装技能 |
| `picoclaw migrate` | 从旧版本迁移数据 |
| `picoclaw auth login` | 认证提供商 |
+| `picoclaw model` | 查看或切换默认模型 |
### 定时任务 / 提醒
diff --git a/docs/ANTIGRAVITY_AUTH.md b/docs/ANTIGRAVITY_AUTH.md
index 89261d899..d88d73c8d 100644
--- a/docs/ANTIGRAVITY_AUTH.md
+++ b/docs/ANTIGRAVITY_AUTH.md
@@ -438,7 +438,7 @@ type ProviderAuthResult = {
### 1. Required Environment/Dependencies
-- Go ≥ 1.21
+- Go ≥ 1.25
- PicoClaw codebase (`pkg/providers/` and `pkg/auth/`)
- `crypto` and `net/http` standard library packages
@@ -584,7 +584,7 @@ Each SSE message (`data: {...}`) is wrapped in a `response` field:
],
"agents": {
"defaults": {
- "model": "gemini-flash"
+ "model_name": "gemini-flash"
}
}
}
@@ -674,7 +674,7 @@ Add a default entry in `pkg/config/defaults.go`:
#### 5. Add Auth Support (Optional)
-If your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/cmd_auth.go`:
+If your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/internal/auth/helpers.go`:
```go
case "your-provider":
@@ -736,7 +736,7 @@ export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/m
- `pkg/auth/store.go` - Auth credential storage (`~/.picoclaw/auth.json`)
- `pkg/providers/factory.go` - Provider factory and protocol routing
- `pkg/providers/types.go` - Provider interface definitions
- - `cmd/picoclaw/cmd_auth.go` - Auth CLI commands
+ - `cmd/picoclaw/internal/auth/helpers.go` - Auth CLI commands
- **Documentation:**
- `docs/ANTIGRAVITY_USAGE.md` - Antigravity usage guide
diff --git a/docs/channels/dingtalk/README.fr.md b/docs/channels/dingtalk/README.fr.md
new file mode 100644
index 000000000..969346d65
--- /dev/null
+++ b/docs/channels/dingtalk/README.fr.md
@@ -0,0 +1,35 @@
+> Retour au [README](../../../README.fr.md)
+
+# DingTalk
+
+DingTalk est la plateforme de communication d'entreprise d'Alibaba, très populaire dans les milieux professionnels chinois. Elle utilise un SDK de streaming pour maintenir des connexions persistantes.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Champ | Type | Requis | Description |
+| ------------- | ------ | ------ | ---------------------------------------------------------------- |
+| enabled | bool | Oui | Activer ou non le canal DingTalk |
+| client_id | string | Oui | Client ID de l'application DingTalk |
+| client_secret | string | Oui | Client Secret de l'application DingTalk |
+| allow_from | array | Non | Liste blanche d'ID utilisateurs ; vide signifie tous les utilisateurs |
+
+## Procédure de configuration
+
+1. Rendez-vous sur la [plateforme ouverte DingTalk](https://open.dingtalk.com/)
+2. Créez une application interne d'entreprise
+3. Obtenez le Client ID et le Client Secret depuis les paramètres de l'application
+4. Configurez OAuth et les abonnements aux événements (si nécessaire)
+5. Renseignez le Client ID et le Client Secret dans le fichier de configuration
diff --git a/docs/channels/dingtalk/README.ja.md b/docs/channels/dingtalk/README.ja.md
new file mode 100644
index 000000000..d44a87820
--- /dev/null
+++ b/docs/channels/dingtalk/README.ja.md
@@ -0,0 +1,35 @@
+> [README](../../../README.ja.md) に戻る
+
+# DingTalk
+
+DingTalkはアリババの企業向けコミュニケーションプラットフォームで、中国のビジネス環境で広く利用されています。ストリーミング SDK を使用して持続的な接続を維持します。
+
+## 設定
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| フィールド | 型 | 必須 | 説明 |
+| ------------- | ------ | ---- | -------------------------------------------- |
+| enabled | bool | はい | DingTalk チャンネルを有効にするかどうか |
+| client_id | string | はい | DingTalk アプリケーションの Client ID |
+| client_secret | string | はい | DingTalk アプリケーションの Client Secret |
+| allow_from | array | いいえ | ユーザーIDのホワイトリスト。空の場合は全ユーザーを許可 |
+
+## セットアップ手順
+
+1. [DingTalk オープンプラットフォーム](https://open.dingtalk.com/) にアクセスする
+2. 企業内部アプリケーションを作成する
+3. アプリケーション設定から Client ID と Client Secret を取得する
+4. OAuth とイベントサブスクリプションを設定する(必要な場合)
+5. Client ID と Client Secret を設定ファイルに入力する
diff --git a/docs/channels/dingtalk/README.md b/docs/channels/dingtalk/README.md
new file mode 100644
index 000000000..a3f23a1e6
--- /dev/null
+++ b/docs/channels/dingtalk/README.md
@@ -0,0 +1,35 @@
+> Back to [README](../../../README.md)
+
+# DingTalk
+
+DingTalk is Alibaba's enterprise communication platform, widely used in Chinese workplaces. It uses a streaming SDK to maintain persistent connections.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Field | Type | Required | Description |
+| ------------- | ------ | -------- | -------------------------------------------------------- |
+| enabled | bool | Yes | Whether to enable the DingTalk channel |
+| client_id | string | Yes | Client ID of the DingTalk application |
+| client_secret | string | Yes | Client Secret of the DingTalk application |
+| allow_from | array | No | User ID whitelist; empty means all users are allowed |
+
+## Setup
+
+1. Go to the [DingTalk Open Platform](https://open.dingtalk.com/)
+2. Create an internal enterprise application
+3. Obtain the Client ID and Client Secret from the application settings
+4. Configure OAuth and event subscriptions (if needed)
+5. Fill in the Client ID and Client Secret in the configuration file
diff --git a/docs/channels/dingtalk/README.pt-br.md b/docs/channels/dingtalk/README.pt-br.md
new file mode 100644
index 000000000..f9056217f
--- /dev/null
+++ b/docs/channels/dingtalk/README.pt-br.md
@@ -0,0 +1,35 @@
+> Voltar ao [README](../../../README.pt-br.md)
+
+# DingTalk
+
+DingTalk é a plataforma de comunicação empresarial da Alibaba, amplamente utilizada no ambiente corporativo chinês. Ela usa um SDK de streaming para manter conexões persistentes.
+
+## Configuração
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Campo | Tipo | Obrigatório | Descrição |
+| ------------- | ------ | ----------- | ---------------------------------------------------------------- |
+| enabled | bool | Sim | Se o canal DingTalk deve ser habilitado |
+| client_id | string | Sim | Client ID do aplicativo DingTalk |
+| client_secret | string | Sim | Client Secret do aplicativo DingTalk |
+| allow_from | array | Não | Lista de permissão de IDs de usuário; vazio permite todos |
+
+## Configuração passo a passo
+
+1. Acesse a [Plataforma Aberta DingTalk](https://open.dingtalk.com/)
+2. Crie um aplicativo interno corporativo
+3. Obtenha o Client ID e o Client Secret nas configurações do aplicativo
+4. Configure OAuth e assinaturas de eventos (se necessário)
+5. Preencha o Client ID e o Client Secret no arquivo de configuração
diff --git a/docs/channels/dingtalk/README.vi.md b/docs/channels/dingtalk/README.vi.md
new file mode 100644
index 000000000..8c060a382
--- /dev/null
+++ b/docs/channels/dingtalk/README.vi.md
@@ -0,0 +1,35 @@
+> Quay lại [README](../../../README.vi.md)
+
+# DingTalk
+
+DingTalk là nền tảng giao tiếp doanh nghiệp của Alibaba, được sử dụng rộng rãi trong môi trường làm việc tại Trung Quốc. Nền tảng này sử dụng SDK streaming để duy trì kết nối liên tục.
+
+## Cấu hình
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Trường | Kiểu | Bắt buộc | Mô tả |
+| ------------- | ------ | -------- | ---------------------------------------------------------------- |
+| enabled | bool | Có | Có bật kênh DingTalk hay không |
+| client_id | string | Có | Client ID của ứng dụng DingTalk |
+| client_secret | string | Có | Client Secret của ứng dụng DingTalk |
+| allow_from | array | Không | Danh sách trắng ID người dùng; để trống cho phép tất cả |
+
+## Quy trình thiết lập
+
+1. Truy cập [Nền tảng mở DingTalk](https://open.dingtalk.com/)
+2. Tạo một ứng dụng nội bộ doanh nghiệp
+3. Lấy Client ID và Client Secret từ cài đặt ứng dụng
+4. Cấu hình OAuth và đăng ký sự kiện (nếu cần)
+5. Điền Client ID và Client Secret vào file cấu hình
diff --git a/docs/channels/dingtalk/README.zh.md b/docs/channels/dingtalk/README.zh.md
index 1e445d0b0..bdaaa1ee1 100644
--- a/docs/channels/dingtalk/README.zh.md
+++ b/docs/channels/dingtalk/README.zh.md
@@ -1,3 +1,5 @@
+> 返回 [README](../../../README.zh.md)
+
# 钉钉
钉钉是阿里巴巴的企业通讯平台,在中国职场中广受欢迎。它采用流式 SDK 来维持持久连接。
diff --git a/docs/channels/discord/README.fr.md b/docs/channels/discord/README.fr.md
new file mode 100644
index 000000000..61c34abb9
--- /dev/null
+++ b/docs/channels/discord/README.fr.md
@@ -0,0 +1,39 @@
+> Retour au [README](../../../README.fr.md)
+
+# Discord
+
+Discord est une application gratuite de chat vocal, vidéo et textuel conçue pour les communautés. PicoClaw se connecte aux serveurs Discord via l'API Bot Discord, avec prise en charge de la réception et de l'envoi de messages.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"],
+ "group_trigger": {
+ "mention_only": false
+ }
+ }
+ }
+}
+```
+
+| Champ | Type | Requis | Description |
+| ------------- | ------ | ------ | --------------------------------------------------------------------------- |
+| enabled | bool | Oui | Activer ou non le canal Discord |
+| token | string | Oui | Token du bot Discord |
+| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs |
+| group_trigger | object | Non | Paramètres de déclenchement de groupe (exemple : { "mention_only": false }) |
+
+## Configuration initiale
+
+1. Accéder au [Portail des développeurs Discord](https://discord.com/developers/applications) et créer une nouvelle application
+2. Activer les Intents :
+ - Message Content Intent
+ - Server Members Intent
+3. Obtenir le Token du bot
+4. Renseigner le Token du bot dans le fichier de configuration
+5. Inviter le bot sur le serveur et lui accorder les permissions nécessaires (ex. envoyer des messages, lire l'historique des messages)
diff --git a/docs/channels/discord/README.ja.md b/docs/channels/discord/README.ja.md
new file mode 100644
index 000000000..ecce30059
--- /dev/null
+++ b/docs/channels/discord/README.ja.md
@@ -0,0 +1,39 @@
+> [README](../../../README.ja.md) に戻る
+
+# Discord
+
+Discord はコミュニティ向けに設計された無料の音声・ビデオ・テキストチャットアプリケーションです。PicoClaw は Discord Bot API を通じて Discord サーバーに接続し、メッセージの受信と送信をサポートします。
+
+## 設定
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"],
+ "group_trigger": {
+ "mention_only": false
+ }
+ }
+ }
+}
+```
+
+| フィールド | 型 | 必須 | 説明 |
+| ------------- | ------ | ------ | ----------------------------------------------------------------- |
+| enabled | bool | はい | Discord チャンネルを有効にするかどうか |
+| token | string | はい | Discord ボットトークン |
+| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 |
+| group_trigger | object | いいえ | グループトリガー設定(例: { "mention_only": false }) |
+
+## セットアップ手順
+
+1. [Discord 開発者ポータル](https://discord.com/developers/applications) にアクセスして新しいアプリケーションを作成する
+2. Intents を有効にする:
+ - Message Content Intent
+ - Server Members Intent
+3. Bot トークンを取得する
+4. 設定ファイルに Bot トークンを入力する
+5. ボットをサーバーに招待し、必要な権限を付与する(例: メッセージの送信、メッセージ履歴の読み取りなど)
diff --git a/docs/channels/discord/README.md b/docs/channels/discord/README.md
new file mode 100644
index 000000000..e1ce7ab06
--- /dev/null
+++ b/docs/channels/discord/README.md
@@ -0,0 +1,39 @@
+> Back to [README](../../../README.md)
+
+# Discord
+
+Discord is a free voice, video, and text chat application designed for communities. PicoClaw connects to Discord servers via the Discord Bot API, supporting both receiving and sending messages.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"],
+ "group_trigger": {
+ "mention_only": false
+ }
+ }
+ }
+}
+```
+
+| Field | Type | Required | Description |
+| ------------- | ------ | -------- | --------------------------------------------------------------------------- |
+| enabled | bool | Yes | Whether to enable the Discord channel |
+| token | string | Yes | Discord Bot Token |
+| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
+| group_trigger | object | No | Group trigger settings (example: { "mention_only": false }) |
+
+## Setup
+
+1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) and create a new application
+2. Enable Intents:
+ - Message Content Intent
+ - Server Members Intent
+3. Obtain the Bot Token
+4. Fill in the Bot Token in the configuration file
+5. Invite the bot to your server and grant the necessary permissions (e.g. Send Messages, Read Message History)
diff --git a/docs/channels/discord/README.pt-br.md b/docs/channels/discord/README.pt-br.md
new file mode 100644
index 000000000..c9ed2809b
--- /dev/null
+++ b/docs/channels/discord/README.pt-br.md
@@ -0,0 +1,39 @@
+> Voltar ao [README](../../../README.pt-br.md)
+
+# Discord
+
+Discord é um aplicativo gratuito de chat de voz, vídeo e texto projetado para comunidades. O PicoClaw se conecta a servidores Discord via Discord Bot API, com suporte para receber e enviar mensagens.
+
+## Configuração
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"],
+ "group_trigger": {
+ "mention_only": false
+ }
+ }
+ }
+}
+```
+
+| Campo | Tipo | Obrigatório | Descrição |
+| ------------- | ------ | ----------- | --------------------------------------------------------------------------- |
+| enabled | bool | Sim | Se o canal Discord deve ser habilitado |
+| token | string | Sim | Token do Bot Discord |
+| allow_from | array | Não | Lista de IDs de usuários permitidos; vazio significa todos os usuários |
+| group_trigger | object | Não | Configurações de gatilho de grupo (exemplo: { "mention_only": false }) |
+
+## Configuração inicial
+
+1. Acesse o [Portal de Desenvolvedores do Discord](https://discord.com/developers/applications) e crie uma nova aplicação
+2. Habilite os Intents:
+ - Message Content Intent
+ - Server Members Intent
+3. Obtenha o Token do Bot
+4. Preencha o Token do Bot no arquivo de configuração
+5. Convide o bot para o servidor e conceda as permissões necessárias (ex. enviar mensagens, ler histórico de mensagens)
diff --git a/docs/channels/discord/README.vi.md b/docs/channels/discord/README.vi.md
new file mode 100644
index 000000000..7073b04f1
--- /dev/null
+++ b/docs/channels/discord/README.vi.md
@@ -0,0 +1,39 @@
+> Quay lại [README](../../../README.vi.md)
+
+# Discord
+
+Discord là ứng dụng chat thoại, video và văn bản miễn phí được thiết kế cho cộng đồng. PicoClaw kết nối với máy chủ Discord qua Discord Bot API, hỗ trợ nhận và gửi tin nhắn.
+
+## Cấu hình
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"],
+ "group_trigger": {
+ "mention_only": false
+ }
+ }
+ }
+}
+```
+
+| Trường | Kiểu | Bắt buộc | Mô tả |
+| ------------- | ------ | -------- | --------------------------------------------------------------------------- |
+| enabled | bool | Có | Có bật kênh Discord hay không |
+| token | string | Có | Token Bot Discord |
+| 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ả |
+| group_trigger | object | Không | Cài đặt kích hoạt nhóm (ví dụ: { "mention_only": false }) |
+
+## Hướng dẫn thiết lập
+
+1. Truy cập [Discord Developer Portal](https://discord.com/developers/applications) và tạo ứng dụng mới
+2. Bật các Intents:
+ - Message Content Intent
+ - Server Members Intent
+3. Lấy Bot Token
+4. Điền Bot Token vào file cấu hình
+5. Mời bot vào máy chủ và cấp các quyền cần thiết (ví dụ: gửi tin nhắn, đọc lịch sử tin nhắn)
diff --git a/docs/channels/discord/README.zh.md b/docs/channels/discord/README.zh.md
index 6d3c502cf..673af4854 100644
--- a/docs/channels/discord/README.zh.md
+++ b/docs/channels/discord/README.zh.md
@@ -1,3 +1,5 @@
+> 返回 [README](../../../README.zh.md)
+
# Discord
Discord 是一个专为社区设计的免费语音、视频和文本聊天应用。PicoClaw 通过 Discord Bot API 连接到 Discord 服务器,支持接收和发送消息。
diff --git a/docs/channels/feishu/README.fr.md b/docs/channels/feishu/README.fr.md
new file mode 100644
index 000000000..555dd2713
--- /dev/null
+++ b/docs/channels/feishu/README.fr.md
@@ -0,0 +1,48 @@
+> Retour au [README](../../../README.fr.md)
+
+# Feishu
+
+Feishu (nom international : Lark) est une plateforme de collaboration d'entreprise de ByteDance. Elle prend en charge les marchés chinois et mondiaux via des connexions WebSocket pilotées par événements.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "feishu": {
+ "enabled": true,
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Champ | Type | Requis | Description |
+| --------------------- | ------ | ------ | --------------------------------------------------------------------------- |
+| enabled | bool | Oui | Activer ou non le canal Feishu |
+| app_id | string | Oui | App ID de l'application Feishu (commence par `cli_`) |
+| app_secret | string | Oui | App Secret de l'application Feishu |
+| encrypt_key | string | Non | Clé de chiffrement pour les callbacks d'événements |
+| verification_token | string | Non | Token utilisé pour la vérification des événements Webhook |
+| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs |
+| random_reaction_emoji | array | Non | Liste d'emojis de réaction aléatoires ; vide utilise le "Pin" par défaut |
+
+## Configuration initiale
+
+1. Accéder à la [plateforme ouverte Feishu](https://open.feishu.cn/) et créer une application
+2. Activer la capacité **Bot** dans les paramètres de l'application
+3. Créer une version et publier l'application (la configuration prend effet après la publication)
+4. Obtenir l'**App ID** (commence par `cli_`) et l'**App Secret**
+5. Renseigner l'App ID et l'App Secret dans le fichier de configuration PicoClaw
+6. Exécuter `picoclaw gateway` pour démarrer le service
+7. Rechercher le nom du bot dans Feishu et commencer une conversation
+
+> PicoClaw se connecte à Feishu en mode WebSocket/SDK — aucune adresse de callback publique ni URL Webhook n'est requise.
+>
+> `encrypt_key` et `verification_token` sont optionnels ; l'activation du chiffrement des événements est recommandée pour les environnements de production.
+>
+> Pour les références d'emojis personnalisés, voir : [Liste des emojis Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
diff --git a/docs/channels/feishu/README.ja.md b/docs/channels/feishu/README.ja.md
new file mode 100644
index 000000000..ca467dd4c
--- /dev/null
+++ b/docs/channels/feishu/README.ja.md
@@ -0,0 +1,48 @@
+> [README](../../../README.ja.md) に戻る
+
+# 飛書(Feishu)
+
+飛書(国際名:Lark)は ByteDance が提供するエンタープライズコラボレーションプラットフォームです。イベント駆動型の WebSocket 接続を通じて、中国および世界市場の両方をサポートします。
+
+## 設定
+
+```json
+{
+ "channels": {
+ "feishu": {
+ "enabled": true,
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| フィールド | 型 | 必須 | 説明 |
+| --------------------- | ------ | ------ | ----------------------------------------------------------------- |
+| enabled | bool | はい | 飛書チャンネルを有効にするかどうか |
+| app_id | string | はい | 飛書アプリケーションの App ID(`cli_` で始まる) |
+| app_secret | string | はい | 飛書アプリケーションの App Secret |
+| encrypt_key | string | いいえ | イベントコールバックの暗号化キー |
+| verification_token | string | いいえ | Webhook イベント検証に使用するトークン |
+| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 |
+| random_reaction_emoji | array | いいえ | ランダムに追加する絵文字のリスト。空の場合はデフォルトの "Pin" を使用 |
+
+## セットアップ手順
+
+1. [飛書オープンプラットフォーム](https://open.feishu.cn/) にアクセスしてアプリケーションを作成する
+2. アプリケーション設定で**ボット**機能を有効にする
+3. バージョンを作成してアプリケーションを公開する(公開後に設定が有効になる)
+4. **App ID**(`cli_` で始まる)と **App Secret** を取得する
+5. PicoClaw 設定ファイルに App ID と App Secret を入力する
+6. `picoclaw gateway` を実行してサービスを起動する
+7. 飛書でボット名を検索して会話を始める
+
+> PicoClaw は WebSocket/SDK モードで飛書に接続するため、公開コールバックアドレスや Webhook URL の設定は不要です。
+>
+> `encrypt_key` と `verification_token` はオプションですが、本番環境ではイベント暗号化を有効にすることを推奨します。
+>
+> カスタム絵文字の参考:[飛書絵文字リスト](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
diff --git a/docs/channels/feishu/README.md b/docs/channels/feishu/README.md
new file mode 100644
index 000000000..a991c76af
--- /dev/null
+++ b/docs/channels/feishu/README.md
@@ -0,0 +1,48 @@
+> Back to [README](../../../README.md)
+
+# Feishu
+
+Feishu (international name: Lark) is an enterprise collaboration platform by ByteDance. It supports both Chinese and global markets through event-driven WebSocket connections.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "feishu": {
+ "enabled": true,
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Field | Type | Required | Description |
+| --------------------- | ------ | -------- | ------------------------------------------------------------------ |
+| enabled | bool | Yes | Whether to enable the Feishu channel |
+| app_id | string | Yes | App ID of the Feishu application (starts with `cli_`) |
+| app_secret | string | Yes | App Secret of the Feishu application |
+| encrypt_key | string | No | Encryption key for event callbacks |
+| verification_token | string | No | Token used for Webhook event verification |
+| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
+| random_reaction_emoji | array | No | List of random reaction emojis; empty uses the default "Pin" |
+
+## Setup
+
+1. Go to the [Feishu Open Platform](https://open.feishu.cn/) and create an application
+2. Enable the **Bot** capability in the application settings
+3. Create a version and publish the application (configuration takes effect only after publishing)
+4. Obtain the **App ID** (starts with `cli_`) and **App Secret**
+5. Fill in the App ID and App Secret in the PicoClaw configuration file
+6. Run `picoclaw gateway` to start the service
+7. Search for the bot name in Feishu and start a conversation
+
+> PicoClaw connects to Feishu using WebSocket/SDK mode — no public callback address or Webhook URL is required.
+>
+> `encrypt_key` and `verification_token` are optional; enabling event encryption is recommended for production environments.
+>
+> For custom emoji references, see: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
diff --git a/docs/channels/feishu/README.pt-br.md b/docs/channels/feishu/README.pt-br.md
new file mode 100644
index 000000000..00a8c95b0
--- /dev/null
+++ b/docs/channels/feishu/README.pt-br.md
@@ -0,0 +1,48 @@
+> Voltar ao [README](../../../README.pt-br.md)
+
+# Feishu
+
+Feishu (nome internacional: Lark) é uma plataforma de colaboração empresarial da ByteDance. Suporta os mercados chinês e global por meio de conexões WebSocket orientadas a eventos.
+
+## Configuração
+
+```json
+{
+ "channels": {
+ "feishu": {
+ "enabled": true,
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Campo | Tipo | Obrigatório | Descrição |
+| --------------------- | ------ | ----------- | -------------------------------------------------------------------------- |
+| enabled | bool | Sim | Se o canal Feishu deve ser habilitado |
+| app_id | string | Sim | App ID da aplicação Feishu (começa com `cli_`) |
+| app_secret | string | Sim | App Secret da aplicação Feishu |
+| encrypt_key | string | Não | Chave de criptografia para callbacks de eventos |
+| verification_token | string | Não | Token usado para verificação de eventos Webhook |
+| allow_from | array | Não | Lista de IDs de usuários permitidos; vazio significa todos os usuários |
+| random_reaction_emoji | array | Não | Lista de emojis de reação aleatórios; vazio usa o "Pin" padrão |
+
+## Configuração inicial
+
+1. Acesse a [Plataforma Aberta Feishu](https://open.feishu.cn/) e crie uma aplicação
+2. Habilite a capacidade de **Bot** nas configurações da aplicação
+3. Crie uma versão e publique a aplicação (a configuração entra em vigor após a publicação)
+4. Obtenha o **App ID** (começa com `cli_`) e o **App Secret**
+5. Preencha o App ID e o App Secret no arquivo de configuração do PicoClaw
+6. Execute `picoclaw gateway` para iniciar o serviço
+7. Pesquise o nome do bot no Feishu e inicie uma conversa
+
+> O PicoClaw se conecta ao Feishu usando o modo WebSocket/SDK — nenhum endereço de callback público ou URL de Webhook é necessário.
+>
+> `encrypt_key` e `verification_token` são opcionais; recomenda-se habilitar a criptografia de eventos em ambientes de produção.
+>
+> Para referências de emojis personalizados, consulte: [Lista de Emojis do Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
diff --git a/docs/channels/feishu/README.vi.md b/docs/channels/feishu/README.vi.md
new file mode 100644
index 000000000..600dce260
--- /dev/null
+++ b/docs/channels/feishu/README.vi.md
@@ -0,0 +1,48 @@
+> Quay lại [README](../../../README.vi.md)
+
+# Feishu
+
+Feishu (tên quốc tế: Lark) là nền tảng cộng tác doanh nghiệp của ByteDance. Hỗ trợ cả thị trường Trung Quốc và toàn cầu thông qua kết nối WebSocket theo hướng sự kiện.
+
+## Cấu hình
+
+```json
+{
+ "channels": {
+ "feishu": {
+ "enabled": true,
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Trường | Kiểu | Bắt buộc | Mô tả |
+| --------------------- | ------ | -------- | ------------------------------------------------------------------------ |
+| enabled | bool | Có | Có bật kênh Feishu hay không |
+| app_id | string | Có | App ID của ứng dụng Feishu (bắt đầu bằng `cli_`) |
+| app_secret | string | Có | App Secret của ứng dụng Feishu |
+| encrypt_key | string | Không | Khóa mã hóa cho callback sự kiện |
+| verification_token | string | Không | Token dùng để xác minh sự kiện Webhook |
+| 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ả |
+| random_reaction_emoji | array | Không | Danh sách emoji phản ứng ngẫu nhiên; để trống dùng "Pin" mặc định |
+
+## Hướng dẫn thiết lập
+
+1. Truy cập [Nền tảng Mở Feishu](https://open.feishu.cn/) và tạo ứng dụng
+2. Bật khả năng **Bot** trong cài đặt ứng dụng
+3. Tạo phiên bản và xuất bản ứng dụng (cấu hình có hiệu lực sau khi xuất bản)
+4. Lấy **App ID** (bắt đầu bằng `cli_`) và **App Secret**
+5. Điền App ID và App Secret vào file cấu hình PicoClaw
+6. Chạy `picoclaw gateway` để khởi động dịch vụ
+7. Tìm kiếm tên bot trong Feishu và bắt đầu trò chuyện
+
+> PicoClaw kết nối với Feishu bằng chế độ WebSocket/SDK — không cần cấu hình địa chỉ callback công khai hay Webhook URL.
+>
+> `encrypt_key` và `verification_token` là tùy chọn; nên bật mã hóa sự kiện trong môi trường sản xuất.
+>
+> Tham khảo emoji tùy chỉnh: [Danh sách Emoji Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md
index db7eb56eb..a967dbdc3 100644
--- a/docs/channels/feishu/README.zh.md
+++ b/docs/channels/feishu/README.zh.md
@@ -1,3 +1,5 @@
+> 返回 [README](../../../README.zh.md)
+
# 飞书
飞书(国际版名称:Lark)是字节跳动旗下的企业协作平台。它通过事件驱动的 Webhook 同时支持中国和全球市场。
@@ -33,9 +35,16 @@
## 设置流程
-1. 前往 [飞书开放平台](https://open.feishu.cn/)(国际版用户请前往 [Lark 开放平台](https://open.larksuite.com/))创建应用程序
-2. 获取 App ID 和 App Secret
-3. 配置事件订阅和Webhook URL
-4. 设置加密(可选,生产环境建议启用)
-5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
-6. 自定义你希望 PicoClaw react 你消息时的表情(可选, Reference URL: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce))
+1. 前往 [飞书开放平台](https://open.feishu.cn/)(国际版用户请前往 [Lark 开放平台](https://open.larksuite.com/))创建应用
+2. 在应用设置中启用**机器人**能力
+3. 创建版本并发布应用(应用发布后配置才会生效)
+4. 获取 **App ID**(以 `cli_` 开头)和 **App Secret**
+5. 将 App ID 和 App Secret 填入 PicoClaw 配置文件
+6. 运行 `picoclaw gateway` 启动服务
+7. 在飞书中搜索机器人名称,开始对话
+
+> PicoClaw 使用 WebSocket/SDK 模式连接飞书,无需配置公网回调地址或 Webhook URL。
+>
+> `encrypt_key` 和 `verification_token` 为可选项,生产环境建议启用事件加密。
+>
+> 自定义表情参考:[飞书表情列表](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
diff --git a/docs/channels/line/README.fr.md b/docs/channels/line/README.fr.md
new file mode 100644
index 000000000..10bdf3e58
--- /dev/null
+++ b/docs/channels/line/README.fr.md
@@ -0,0 +1,40 @@
+> Retour au [README](../../../README.fr.md)
+
+# Line
+
+PicoClaw prend en charge LINE via l'API LINE Messaging avec des callbacks webhook.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "YOUR_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Champ | Type | Requis | Description |
+| -------------------- | ------ | ------ | ------------------------------------------------------------------------ |
+| enabled | bool | Oui | Activer ou non le canal LINE |
+| channel_secret | string | Oui | Channel Secret de l'API LINE Messaging |
+| channel_access_token | string | Oui | Channel Access Token de l'API LINE Messaging |
+| webhook_path | string | Non | Chemin du webhook (par défaut : /webhook/line) |
+| allow_from | array | Non | Liste blanche d'ID utilisateurs ; vide signifie tous les utilisateurs |
+
+## Procédure de configuration
+
+1. Rendez-vous sur la [LINE Developers Console](https://developers.line.biz/console/) et créez un fournisseur de services ainsi qu'un canal Messaging API
+2. Obtenez le Channel Secret et le Channel Access Token
+3. Configurez le webhook :
+ - LINE exige que les webhooks utilisent HTTPS. Vous devez donc déployer un serveur compatible HTTPS ou utiliser un outil de proxy inverse comme ngrok pour exposer votre serveur local sur Internet
+ - PicoClaw utilise un serveur HTTP Gateway partagé pour recevoir les callbacks webhook de tous les canaux, écoutant par défaut sur 127.0.0.1:18790
+ - Définissez l'URL du webhook sur `https://your-domain.com/webhook/line`, puis configurez un proxy inverse de votre domaine externe vers le Gateway local (port par défaut 18790)
+ - Activez le webhook et vérifiez l'URL
+4. Renseignez le Channel Secret et le Channel Access Token dans le fichier de configuration
diff --git a/docs/channels/line/README.ja.md b/docs/channels/line/README.ja.md
new file mode 100644
index 000000000..0e559093a
--- /dev/null
+++ b/docs/channels/line/README.ja.md
@@ -0,0 +1,40 @@
+> [README](../../../README.ja.md) に戻る
+
+# Line
+
+PicoClaw は LINE Messaging API と Webhook コールバックを通じて LINE をサポートします。
+
+## 設定
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "YOUR_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| フィールド | 型 | 必須 | 説明 |
+| -------------------- | ------ | ------ | ------------------------------------------------------------------ |
+| enabled | bool | はい | LINE チャンネルを有効にするかどうか |
+| channel_secret | string | はい | LINE Messaging API の Channel Secret |
+| channel_access_token | string | はい | LINE Messaging API の Channel Access Token |
+| webhook_path | string | いいえ | Webhook のパス(デフォルト: /webhook/line) |
+| allow_from | array | いいえ | ユーザーIDのホワイトリスト。空の場合は全ユーザーを許可 |
+
+## セットアップ手順
+
+1. [LINE Developers Console](https://developers.line.biz/console/) にアクセスし、サービスプロバイダーと Messaging API チャンネルを作成する
+2. Channel Secret と Channel Access Token を取得する
+3. Webhook を設定する:
+ - LINE は Webhook に HTTPS が必要なため、HTTPS 対応サーバーをデプロイするか、ngrok などのリバースプロキシツールを使用してローカルサーバーをインターネットに公開する必要があります
+ - PicoClaw は共有の Gateway HTTP サーバーを使用してすべてのチャンネルの Webhook コールバックを受信します。デフォルトのリッスンアドレスは 127.0.0.1:18790 です
+ - Webhook URL を `https://your-domain.com/webhook/line` に設定し、外部ドメインをローカルの Gateway(デフォルトポート 18790)にリバースプロキシする
+ - Webhook を有効にして URL を検証する
+4. Channel Secret と Channel Access Token を設定ファイルに入力する
diff --git a/docs/channels/line/README.md b/docs/channels/line/README.md
new file mode 100644
index 000000000..1aad18eee
--- /dev/null
+++ b/docs/channels/line/README.md
@@ -0,0 +1,40 @@
+> Back to [README](../../../README.md)
+
+# Line
+
+PicoClaw supports LINE through the LINE Messaging API with webhook callbacks.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "YOUR_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Field | Type | Required | Description |
+| -------------------- | ------ | -------- | ------------------------------------------------------------------ |
+| enabled | bool | Yes | Whether to enable the LINE channel |
+| channel_secret | string | Yes | Channel Secret for the LINE Messaging API |
+| channel_access_token | string | Yes | Channel Access Token for the LINE Messaging API |
+| webhook_path | string | No | Webhook path (default: /webhook/line) |
+| allow_from | array | No | User ID whitelist; empty means all users are allowed |
+
+## Setup
+
+1. Go to the [LINE Developers Console](https://developers.line.biz/console/) and create a provider and a Messaging API channel
+2. Obtain the Channel Secret and Channel Access Token
+3. Configure the webhook:
+ - LINE requires webhooks to use HTTPS, so you need to deploy a server with HTTPS support, or use a reverse proxy tool like ngrok to expose your local server to the internet
+ - PicoClaw uses a shared Gateway HTTP server to receive webhook callbacks for all channels, listening on 127.0.0.1:18790 by default
+ - Set the Webhook URL to `https://your-domain.com/webhook/line`, then reverse-proxy your external domain to the local Gateway (default port 18790)
+ - Enable the webhook and verify the URL
+4. Fill in the Channel Secret and Channel Access Token in the configuration file
diff --git a/docs/channels/line/README.pt-br.md b/docs/channels/line/README.pt-br.md
new file mode 100644
index 000000000..b3334461f
--- /dev/null
+++ b/docs/channels/line/README.pt-br.md
@@ -0,0 +1,40 @@
+> Voltar ao [README](../../../README.pt-br.md)
+
+# Line
+
+O PicoClaw suporta o LINE por meio da LINE Messaging API com callbacks de webhook.
+
+## Configuração
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "YOUR_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Campo | Tipo | Obrigatório | Descrição |
+| -------------------- | ------ | ----------- | ---------------------------------------------------------------------- |
+| enabled | bool | Sim | Se o canal LINE deve ser habilitado |
+| channel_secret | string | Sim | Channel Secret da LINE Messaging API |
+| channel_access_token | string | Sim | Channel Access Token da LINE Messaging API |
+| webhook_path | string | Não | Caminho do webhook (padrão: /webhook/line) |
+| allow_from | array | Não | Lista de permissão de IDs de usuário; vazio permite todos |
+
+## Configuração passo a passo
+
+1. Acesse o [LINE Developers Console](https://developers.line.biz/console/) e crie um provedor de serviços e um canal Messaging API
+2. Obtenha o Channel Secret e o Channel Access Token
+3. Configure o webhook:
+ - O LINE exige que os webhooks usem HTTPS, portanto é necessário implantar um servidor com suporte a HTTPS ou usar uma ferramenta de proxy reverso como o ngrok para expor seu servidor local à internet
+ - O PicoClaw usa um servidor HTTP Gateway compartilhado para receber callbacks de webhook de todos os canais, escutando em 127.0.0.1:18790 por padrão
+ - Defina a URL do webhook como `https://your-domain.com/webhook/line` e configure um proxy reverso do seu domínio externo para o Gateway local (porta padrão 18790)
+ - Ative o webhook e verifique a URL
+4. Preencha o Channel Secret e o Channel Access Token no arquivo de configuração
diff --git a/docs/channels/line/README.vi.md b/docs/channels/line/README.vi.md
new file mode 100644
index 000000000..3e5511a84
--- /dev/null
+++ b/docs/channels/line/README.vi.md
@@ -0,0 +1,40 @@
+> Quay lại [README](../../../README.vi.md)
+
+# Line
+
+PicoClaw hỗ trợ LINE thông qua LINE Messaging API kết hợp với webhook callback.
+
+## Cấu hình
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "YOUR_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Trường | Kiểu | Bắt buộc | Mô tả |
+| -------------------- | ------ | -------- | ---------------------------------------------------------------------- |
+| enabled | bool | Có | Có bật kênh LINE hay không |
+| channel_secret | string | Có | Channel Secret của LINE Messaging API |
+| channel_access_token | string | Có | Channel Access Token của LINE Messaging API |
+| webhook_path | string | Không | Đường dẫn webhook (mặc định: /webhook/line) |
+| allow_from | array | Không | Danh sách trắng ID người dùng; để trống cho phép tất cả |
+
+## Quy trình thiết lập
+
+1. Truy cập [LINE Developers Console](https://developers.line.biz/console/) và tạo một nhà cung cấp dịch vụ cùng một kênh Messaging API
+2. Lấy Channel Secret và Channel Access Token
+3. Cấu hình webhook:
+ - LINE yêu cầu webhook phải sử dụng HTTPS, vì vậy bạn cần triển khai máy chủ hỗ trợ HTTPS hoặc dùng công cụ reverse proxy như ngrok để expose máy chủ cục bộ ra internet
+ - PicoClaw sử dụng máy chủ HTTP Gateway dùng chung để nhận webhook callback cho tất cả các kênh, mặc định lắng nghe tại 127.0.0.1:18790
+ - Đặt Webhook URL thành `https://your-domain.com/webhook/line`, sau đó reverse proxy tên miền bên ngoài về Gateway cục bộ (cổng mặc định 18790)
+ - Bật webhook và xác minh URL
+4. Điền Channel Secret và Channel Access Token vào file cấu hình
diff --git a/docs/channels/line/README.zh.md b/docs/channels/line/README.zh.md
index a36f622c2..0f7dd0cd8 100644
--- a/docs/channels/line/README.zh.md
+++ b/docs/channels/line/README.zh.md
@@ -1,3 +1,5 @@
+> 返回 [README](../../../README.zh.md)
+
# Line
PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的支持。
diff --git a/docs/channels/maixcam/README.fr.md b/docs/channels/maixcam/README.fr.md
new file mode 100644
index 000000000..8fddb203a
--- /dev/null
+++ b/docs/channels/maixcam/README.fr.md
@@ -0,0 +1,35 @@
+> Retour au [README](../../../README.fr.md)
+
+# MaixCam
+
+MaixCam est un canal dédié à la connexion aux caméras AI Sipeed MaixCAM et MaixCAM2. Il utilise des sockets TCP pour une communication bidirectionnelle et prend en charge les scénarios de déploiement d'IA en périphérie.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "maixcam": {
+ "enabled": true,
+ "host": "0.0.0.0",
+ "port": 18790,
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Champ | Type | Requis | Description |
+| ---------- | ------ | ------ | --------------------------------------------------------------------------- |
+| enabled | bool | Oui | Activer ou non le canal MaixCam |
+| host | string | Oui | Adresse d'écoute du serveur TCP |
+| port | int | Oui | Port d'écoute du serveur TCP |
+| allow_from | array | Non | Liste blanche d'identifiants d'appareils ; vide signifie tous les appareils |
+
+## Cas d'utilisation
+
+Le canal MaixCam permet à PicoClaw de fonctionner comme backend IA pour les appareils en périphérie :
+
+- **Surveillance intelligente** : MaixCAM envoie des images ; PicoClaw les analyse via des modèles de vision
+- **Contrôle IoT** : Les appareils envoient des données de capteurs ; PicoClaw coordonne les réponses
+- **IA hors ligne** : Déployer PicoClaw sur un réseau local pour une inférence à faible latence
diff --git a/docs/channels/maixcam/README.ja.md b/docs/channels/maixcam/README.ja.md
new file mode 100644
index 000000000..0a5f27baa
--- /dev/null
+++ b/docs/channels/maixcam/README.ja.md
@@ -0,0 +1,35 @@
+> [README](../../../README.ja.md) に戻る
+
+# MaixCam
+
+MaixCam は、Sipeed MaixCAM および MaixCAM2 AI カメラデバイスへの接続専用チャンネルです。TCP ソケットを使用した双方向通信を実装し、エッジ AI デプロイメントシナリオをサポートします。
+
+## 設定
+
+```json
+{
+ "channels": {
+ "maixcam": {
+ "enabled": true,
+ "host": "0.0.0.0",
+ "port": 18790,
+ "allow_from": []
+ }
+ }
+}
+```
+
+| フィールド | 型 | 必須 | 説明 |
+| ---------- | ------ | ------ | ------------------------------------------------------------- |
+| enabled | bool | はい | MaixCam チャンネルを有効にするかどうか |
+| host | string | はい | TCP サーバーのリッスンアドレス |
+| port | int | はい | TCP サーバーのリッスンポート |
+| allow_from | array | いいえ | 許可するデバイスIDのリスト。空の場合はすべてのデバイスを許可 |
+
+## ユースケース
+
+MaixCam チャンネルにより、PicoClaw はエッジデバイスの AI バックエンドとして機能できます:
+
+- **スマート監視**:MaixCAM が画像フレームを送信し、PicoClaw がビジョンモデルで分析する
+- **IoT 制御**:デバイスがセンサーデータを送信し、PicoClaw がレスポンスを調整する
+- **オフライン AI**:ローカルネットワークに PicoClaw をデプロイして低遅延推論を実現する
diff --git a/docs/channels/maixcam/README.md b/docs/channels/maixcam/README.md
new file mode 100644
index 000000000..c22c9236f
--- /dev/null
+++ b/docs/channels/maixcam/README.md
@@ -0,0 +1,35 @@
+> Back to [README](../../../README.md)
+
+# MaixCam
+
+MaixCam is a dedicated channel for connecting to Sipeed MaixCAM and MaixCAM2 AI camera devices. It uses TCP sockets for bidirectional communication and supports edge AI deployment scenarios.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "maixcam": {
+ "enabled": true,
+ "host": "0.0.0.0",
+ "port": 18790,
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Field | Type | Required | Description |
+| ---------- | ------ | -------- | ---------------------------------------------------------------- |
+| enabled | bool | Yes | Whether to enable the MaixCam channel |
+| host | string | Yes | TCP server listening address |
+| port | int | Yes | TCP server listening port |
+| allow_from | array | No | Allowlist of device IDs; empty means all devices are allowed |
+
+## Use Cases
+
+The MaixCam channel enables PicoClaw to act as an AI backend for edge devices:
+
+- **Smart Surveillance**: MaixCAM sends image frames; PicoClaw analyzes them using vision models
+- **IoT Control**: Devices send sensor data; PicoClaw coordinates responses
+- **Offline AI**: Deploy PicoClaw on a local network for low-latency inference
diff --git a/docs/channels/maixcam/README.pt-br.md b/docs/channels/maixcam/README.pt-br.md
new file mode 100644
index 000000000..81a1f3f00
--- /dev/null
+++ b/docs/channels/maixcam/README.pt-br.md
@@ -0,0 +1,35 @@
+> Voltar ao [README](../../../README.pt-br.md)
+
+# MaixCam
+
+MaixCam é um canal dedicado para conectar dispositivos de câmera AI Sipeed MaixCAM e MaixCAM2. Utiliza sockets TCP para comunicação bidirecional e suporta cenários de implantação de IA na borda.
+
+## Configuração
+
+```json
+{
+ "channels": {
+ "maixcam": {
+ "enabled": true,
+ "host": "0.0.0.0",
+ "port": 18790,
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Campo | Tipo | Obrigatório | Descrição |
+| ---------- | ------ | ----------- | -------------------------------------------------------------------------- |
+| enabled | bool | Sim | Se o canal MaixCam deve ser habilitado |
+| host | string | Sim | Endereço de escuta do servidor TCP |
+| port | int | Sim | Porta de escuta do servidor TCP |
+| allow_from | array | Não | Lista de IDs de dispositivos permitidos; vazio significa todos os dispositivos |
+
+## Casos de uso
+
+O canal MaixCam permite que o PicoClaw atue como backend de IA para dispositivos de borda:
+
+- **Vigilância inteligente**: MaixCAM envia quadros de imagem; PicoClaw os analisa usando modelos de visão
+- **Controle IoT**: Dispositivos enviam dados de sensores; PicoClaw coordena as respostas
+- **IA offline**: Implante o PicoClaw em uma rede local para inferência de baixa latência
diff --git a/docs/channels/maixcam/README.vi.md b/docs/channels/maixcam/README.vi.md
new file mode 100644
index 000000000..8955bae86
--- /dev/null
+++ b/docs/channels/maixcam/README.vi.md
@@ -0,0 +1,35 @@
+> Quay lại [README](../../../README.vi.md)
+
+# MaixCam
+
+MaixCam là kênh chuyên dụng để kết nối với các thiết bị camera AI Sipeed MaixCAM và MaixCAM2. Sử dụng TCP socket để giao tiếp hai chiều và hỗ trợ các kịch bản triển khai AI tại biên.
+
+## Cấu hình
+
+```json
+{
+ "channels": {
+ "maixcam": {
+ "enabled": true,
+ "host": "0.0.0.0",
+ "port": 18790,
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Trường | Kiểu | Bắt buộc | Mô tả |
+| ---------- | ------ | -------- | ------------------------------------------------------------------------ |
+| enabled | bool | Có | Có bật kênh MaixCam hay không |
+| host | string | Có | Địa chỉ lắng nghe của máy chủ TCP |
+| port | int | Có | Cổng lắng nghe của máy chủ TCP |
+| allow_from | array | Không | Danh sách trắng ID thiết bị; để trống nghĩa là cho phép tất cả thiết bị |
+
+## Trường hợp sử dụng
+
+Kênh MaixCam cho phép PicoClaw hoạt động như backend AI cho các thiết bị biên:
+
+- **Giám sát thông minh**: MaixCAM gửi khung hình ảnh; PicoClaw phân tích bằng mô hình thị giác
+- **Điều khiển IoT**: Thiết bị gửi dữ liệu cảm biến; PicoClaw điều phối phản hồi
+- **AI ngoại tuyến**: Triển khai PicoClaw trên mạng nội bộ để suy luận độ trễ thấp
diff --git a/docs/channels/maixcam/README.zh.md b/docs/channels/maixcam/README.zh.md
index 8d53d4bef..b0d58e733 100644
--- a/docs/channels/maixcam/README.zh.md
+++ b/docs/channels/maixcam/README.zh.md
@@ -1,3 +1,5 @@
+> 返回 [README](../../../README.zh.md)
+
# MaixCam
MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的通道。它采用 TCP 套接字实现双向通信,支持边缘 AI 部署场景。
@@ -9,18 +11,20 @@ MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的
"channels": {
"maixcam": {
"enabled": true,
- "server_address": "0.0.0.0:8899",
+ "host": "0.0.0.0",
+ "port": 18790,
"allow_from": []
}
}
}
```
-| 字段 | 类型 | 必填 | 描述 |
-| -------------- | ------ | ---- | -------------------------------- |
-| enabled | bool | 是 | 是否启用 MaixCam 频道 |
-| server_address | string | 是 | TCP 服务器监听地址和端口 |
-| allow_from | array | 否 | 设备ID白名单,空表示允许所有设备 |
+| 字段 | 类型 | 必填 | 描述 |
+| ---------- | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用 MaixCam 频道 |
+| host | string | 是 | TCP 服务器监听地址 |
+| port | int | 是 | TCP 服务器监听端口 |
+| allow_from | array | 否 | 设备ID白名单,空表示允许所有设备 |
## 使用场景
diff --git a/docs/channels/matrix/README.zh.md b/docs/channels/matrix/README.zh.md
index efbc13093..1f9e5bbe2 100644
--- a/docs/channels/matrix/README.zh.md
+++ b/docs/channels/matrix/README.zh.md
@@ -42,6 +42,7 @@
| group_trigger | object | 否 | 群聊触发策略(支持 `mention_only` / `prefixes`) |
| placeholder | object | 否 | 占位消息配置 |
| reasoning_channel_id | string | 否 | 思维链输出目标通道 |
+| message_format | string | 否 | 消息格式:`richtext`(富文本)或 `plain`(纯文本) |
## 3. 当前支持
diff --git a/docs/channels/onebot/README.fr.md b/docs/channels/onebot/README.fr.md
new file mode 100644
index 000000000..7c9ffe1d3
--- /dev/null
+++ b/docs/channels/onebot/README.fr.md
@@ -0,0 +1,33 @@
+> Retour au [README](../../../README.fr.md)
+
+# OneBot
+
+OneBot est un standard de protocole ouvert pour les bots QQ, fournissant une interface unifiée pour diverses implémentations de bots QQ (par exemple go-cqhttp, Mirai). Il utilise WebSocket pour la communication.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "onebot": {
+ "enabled": true,
+ "ws_url": "ws://localhost:8080",
+ "access_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Champ | Type | Requis | Description |
+| ------------ | ------ | ------ | -------------------------------------------------------------------- |
+| enabled | bool | Oui | Activer ou non le canal OneBot |
+| ws_url | string | Oui | URL WebSocket du serveur OneBot |
+| access_token | string | Non | Jeton d'accès pour la connexion au serveur OneBot |
+| allow_from | array | Non | Liste blanche d'ID utilisateurs ; vide signifie tous les utilisateurs |
+
+## Procédure de configuration
+
+1. Déployez une implémentation compatible OneBot (par exemple napcat)
+2. Configurez l'implémentation OneBot pour activer le service WebSocket et définir un jeton d'accès (si nécessaire)
+3. Renseignez l'URL WebSocket et le jeton d'accès dans le fichier de configuration
diff --git a/docs/channels/onebot/README.ja.md b/docs/channels/onebot/README.ja.md
new file mode 100644
index 000000000..ce628572b
--- /dev/null
+++ b/docs/channels/onebot/README.ja.md
@@ -0,0 +1,33 @@
+> [README](../../../README.ja.md) に戻る
+
+# OneBot
+
+OneBot は QQ ボット向けのオープンプロトコル標準で、複数の QQ ボット実装(例: go-cqhttp、Mirai)に統一されたインターフェースを提供します。通信には WebSocket を使用します。
+
+## 設定
+
+```json
+{
+ "channels": {
+ "onebot": {
+ "enabled": true,
+ "ws_url": "ws://localhost:8080",
+ "access_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| フィールド | 型 | 必須 | 説明 |
+| ------------ | ------ | ------ | ---------------------------------------------------------------- |
+| enabled | bool | はい | OneBot チャンネルを有効にするかどうか |
+| ws_url | string | はい | OneBot サーバーの WebSocket URL |
+| access_token | string | いいえ | OneBot サーバーへの接続に使用するアクセストークン |
+| allow_from | array | いいえ | ユーザーIDのホワイトリスト。空の場合は全ユーザーを許可 |
+
+## セットアップ手順
+
+1. OneBot 互換の実装(例: napcat)をデプロイする
+2. OneBot 実装で WebSocket サービスを有効にし、アクセストークンを設定する(必要な場合)
+3. WebSocket URL とアクセストークンを設定ファイルに入力する
diff --git a/docs/channels/onebot/README.md b/docs/channels/onebot/README.md
new file mode 100644
index 000000000..42af39b4e
--- /dev/null
+++ b/docs/channels/onebot/README.md
@@ -0,0 +1,33 @@
+> Back to [README](../../../README.md)
+
+# OneBot
+
+OneBot is an open protocol standard for QQ bots, providing a unified interface for various QQ bot implementations (e.g. go-cqhttp, Mirai). It uses WebSocket for communication.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "onebot": {
+ "enabled": true,
+ "ws_url": "ws://localhost:8080",
+ "access_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Field | Type | Required | Description |
+| ------------ | ------ | -------- | ---------------------------------------------------------------- |
+| enabled | bool | Yes | Whether to enable the OneBot channel |
+| ws_url | string | Yes | WebSocket URL of the OneBot server |
+| access_token | string | No | Access token for connecting to the OneBot server |
+| allow_from | array | No | User ID whitelist; empty means all users are allowed |
+
+## Setup
+
+1. Deploy a OneBot-compatible implementation (e.g. napcat)
+2. Configure the OneBot implementation to enable the WebSocket service and set an access token (if needed)
+3. Fill in the WebSocket URL and access token in the configuration file
diff --git a/docs/channels/onebot/README.pt-br.md b/docs/channels/onebot/README.pt-br.md
new file mode 100644
index 000000000..5323163ee
--- /dev/null
+++ b/docs/channels/onebot/README.pt-br.md
@@ -0,0 +1,33 @@
+> Voltar ao [README](../../../README.pt-br.md)
+
+# OneBot
+
+OneBot é um padrão de protocolo aberto para bots QQ, fornecendo uma interface unificada para diversas implementações de bots QQ (ex.: go-cqhttp, Mirai). Utiliza WebSocket para comunicação.
+
+## Configuração
+
+```json
+{
+ "channels": {
+ "onebot": {
+ "enabled": true,
+ "ws_url": "ws://localhost:8080",
+ "access_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Campo | Tipo | Obrigatório | Descrição |
+| ------------ | ------ | ----------- | -------------------------------------------------------------------- |
+| enabled | bool | Sim | Se o canal OneBot deve ser habilitado |
+| ws_url | string | Sim | URL WebSocket do servidor OneBot |
+| access_token | string | Não | Token de acesso para conexão ao servidor OneBot |
+| allow_from | array | Não | Lista de permissão de IDs de usuário; vazio permite todos |
+
+## Configuração passo a passo
+
+1. Implante uma implementação compatível com OneBot (ex.: napcat)
+2. Configure a implementação OneBot para habilitar o serviço WebSocket e definir um token de acesso (se necessário)
+3. Preencha a URL WebSocket e o token de acesso no arquivo de configuração
diff --git a/docs/channels/onebot/README.vi.md b/docs/channels/onebot/README.vi.md
new file mode 100644
index 000000000..a572e7afa
--- /dev/null
+++ b/docs/channels/onebot/README.vi.md
@@ -0,0 +1,33 @@
+> Quay lại [README](../../../README.vi.md)
+
+# OneBot
+
+OneBot là tiêu chuẩn giao thức mở dành cho bot QQ, cung cấp giao diện thống nhất cho nhiều triển khai bot QQ khác nhau (ví dụ: go-cqhttp, Mirai). Nó sử dụng WebSocket để giao tiếp.
+
+## Cấu hình
+
+```json
+{
+ "channels": {
+ "onebot": {
+ "enabled": true,
+ "ws_url": "ws://localhost:8080",
+ "access_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Trường | Kiểu | Bắt buộc | Mô tả |
+| ------------ | ------ | -------- | -------------------------------------------------------------------- |
+| enabled | bool | Có | Có bật kênh OneBot hay không |
+| ws_url | string | Có | URL WebSocket của máy chủ OneBot |
+| access_token | string | Không | Token truy cập để kết nối với máy chủ OneBot |
+| allow_from | array | Không | Danh sách trắng ID người dùng; để trống cho phép tất cả |
+
+## Quy trình thiết lập
+
+1. Triển khai một bản triển khai tương thích OneBot (ví dụ: napcat)
+2. Cấu hình bản triển khai OneBot để bật dịch vụ WebSocket và đặt token truy cập (nếu cần)
+3. Điền URL WebSocket và token truy cập vào file cấu hình
diff --git a/docs/channels/onebot/README.zh.md b/docs/channels/onebot/README.zh.md
index 6195f1c98..8caba0b80 100644
--- a/docs/channels/onebot/README.zh.md
+++ b/docs/channels/onebot/README.zh.md
@@ -1,3 +1,5 @@
+> 返回 [README](../../../README.zh.md)
+
# OneBot
OneBot 是一个面向 QQ 机器人的开放协议标准,为多种 QQ 机器人实现(例如 go-cqhttp、Mirai)提供了统一的接口。它使用 WebSocket 进行通信。
diff --git a/docs/channels/qq/README.fr.md b/docs/channels/qq/README.fr.md
new file mode 100644
index 000000000..38de1b751
--- /dev/null
+++ b/docs/channels/qq/README.fr.md
@@ -0,0 +1,54 @@
+> Retour au [README](../../../README.fr.md)
+
+# QQ
+
+PicoClaw prend en charge QQ via l'API Bot officielle de la plateforme ouverte QQ.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Champ | Type | Requis | Description |
+| ---------- | ------ | ------ | --------------------------------------------------------------------------- |
+| enabled | bool | Oui | Activer ou non le canal QQ |
+| app_id | string | Oui | App ID de l'application bot QQ |
+| app_secret | string | Oui | App Secret de l'application bot QQ |
+| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs |
+
+## Configuration initiale
+
+### Configuration rapide (recommandée)
+
+La plateforme ouverte QQ propose une entrée de création en un clic :
+
+1. Ouvrir [QQ Bot Quick Create](https://q.qq.com/qqbot/openclaw/index.html) et se connecter en scannant le QR code
+2. Le système crée automatiquement un bot — copier l'**App ID** et l'**App Secret**
+3. Renseigner les identifiants dans le fichier de configuration PicoClaw
+4. Exécuter `picoclaw gateway` pour démarrer le service
+5. Ouvrir QQ et commencer à discuter avec le bot
+
+> L'App Secret n'est affiché qu'une seule fois — sauvegardez-le immédiatement. Le consulter à nouveau forcera une réinitialisation.
+>
+> Les bots créés via l'entrée rapide sont réservés à l'usage personnel du créateur et ne prennent pas en charge les discussions de groupe. Pour la prise en charge des groupes, configurez le mode sandbox sur la [plateforme ouverte QQ](https://q.qq.com/).
+
+### Configuration manuelle
+
+1. Se connecter à la [plateforme ouverte QQ](https://q.qq.com/) avec son compte QQ et s'inscrire en tant que développeur
+2. Créer un bot QQ et personnaliser son avatar et son nom
+3. Obtenir l'**App ID** et l'**App Secret** dans les paramètres du bot
+4. Renseigner les identifiants dans le fichier de configuration PicoClaw
+5. Exécuter `picoclaw gateway` pour démarrer le service
+6. Rechercher votre bot dans QQ et commencer à discuter
+
+> Pendant le développement, il est recommandé d'activer le mode sandbox et d'y ajouter les utilisateurs et groupes de test pour le débogage.
diff --git a/docs/channels/qq/README.ja.md b/docs/channels/qq/README.ja.md
new file mode 100644
index 000000000..2990f9622
--- /dev/null
+++ b/docs/channels/qq/README.ja.md
@@ -0,0 +1,54 @@
+> [README](../../../README.ja.md) に戻る
+
+# QQ
+
+PicoClaw は QQ オープンプラットフォームの公式 Bot API を通じて QQ をサポートします。
+
+## 設定
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| フィールド | 型 | 必須 | 説明 |
+| ---------- | ------ | ------ | ------------------------------------------------------------- |
+| enabled | bool | はい | QQ チャンネルを有効にするかどうか |
+| app_id | string | はい | QQ ボットアプリケーションの App ID |
+| app_secret | string | はい | QQ ボットアプリケーションの App Secret |
+| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 |
+
+## セットアップ手順
+
+### クイックセットアップ(推奨)
+
+QQ オープンプラットフォームにはワンクリック作成エントリーが用意されています:
+
+1. [QQ ボットクイック作成](https://q.qq.com/qqbot/openclaw/index.html) を開き、QR コードをスキャンしてログインする
+2. システムが自動的にボットを作成するので、**App ID** と **App Secret** をコピーする
+3. PicoClaw 設定ファイルに認証情報を入力する
+4. `picoclaw gateway` を実行してサービスを起動する
+5. QQ を開いてボットとの会話を始める
+
+> App Secret は一度しか表示されません。すぐに保存してください。再度表示しようとすると強制的にリセットされます。
+>
+> クイックエントリーで作成したボットは作成者本人のみが使用でき、グループチャットには対応していません。グループチャット機能が必要な場合は、[QQ オープンプラットフォーム](https://q.qq.com/) でサンドボックスモードを設定してください。
+
+### 手動セットアップ
+
+1. QQ アカウントで [QQ オープンプラットフォーム](https://q.qq.com/) にログインし、開発者アカウントを登録する
+2. QQ ボットを作成し、アバターと名前をカスタマイズする
+3. ボット設定から **App ID** と **App Secret** を取得する
+4. PicoClaw 設定ファイルに認証情報を入力する
+5. `picoclaw gateway` を実行してサービスを起動する
+6. QQ でボットを検索して会話を始める
+
+> 開発段階ではサンドボックスモードを有効にし、テストユーザーとグループをサンドボックスに追加してデバッグすることを推奨します。
diff --git a/docs/channels/qq/README.md b/docs/channels/qq/README.md
new file mode 100644
index 000000000..35e4a769c
--- /dev/null
+++ b/docs/channels/qq/README.md
@@ -0,0 +1,54 @@
+> Back to [README](../../../README.md)
+
+# QQ
+
+PicoClaw provides QQ support via the official Bot API from the QQ Open Platform.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Field | Type | Required | Description |
+| ---------- | ------ | -------- | -------------------------------------------------------- |
+| enabled | bool | Yes | Whether to enable the QQ channel |
+| app_id | string | Yes | App ID of the QQ bot application |
+| app_secret | string | Yes | App Secret of the QQ bot application |
+| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
+
+## Setup
+
+### Quick Setup (Recommended)
+
+The QQ Open Platform provides a one-click creation entry:
+
+1. Open [QQ Bot Quick Create](https://q.qq.com/qqbot/openclaw/index.html) and log in by scanning the QR code
+2. The system automatically creates a bot — copy the **App ID** and **App Secret**
+3. Fill in the credentials in the PicoClaw configuration file
+4. Run `picoclaw gateway` to start the service
+5. Open QQ and start chatting with the bot
+
+> The App Secret is only shown once — save it immediately. Viewing it again will force a reset.
+>
+> Bots created via the quick entry are for the creator's personal use only and do not support group chats. For group chat support, configure sandbox mode on the [QQ Open Platform](https://q.qq.com/).
+
+### Manual Setup
+
+1. Log in to the [QQ Open Platform](https://q.qq.com/) with your QQ account and register as a developer
+2. Create a QQ bot and customize its avatar and name
+3. Obtain the **App ID** and **App Secret** from the bot settings
+4. Fill in the credentials in the PicoClaw configuration file
+5. Run `picoclaw gateway` to start the service
+6. Search for your bot in QQ and start chatting
+
+> During development, it is recommended to enable sandbox mode and add test users and groups to the sandbox for debugging.
diff --git a/docs/channels/qq/README.pt-br.md b/docs/channels/qq/README.pt-br.md
new file mode 100644
index 000000000..507df7f7e
--- /dev/null
+++ b/docs/channels/qq/README.pt-br.md
@@ -0,0 +1,54 @@
+> Voltar ao [README](../../../README.pt-br.md)
+
+# QQ
+
+O PicoClaw oferece suporte ao QQ via API Bot oficial da Plataforma Aberta QQ.
+
+## Configuração
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Campo | Tipo | Obrigatório | Descrição |
+| ---------- | ------ | ----------- | -------------------------------------------------------------------------- |
+| enabled | bool | Sim | Se o canal QQ deve ser habilitado |
+| app_id | string | Sim | App ID da aplicação bot QQ |
+| app_secret | string | Sim | App Secret da aplicação bot QQ |
+| allow_from | array | Não | Lista de IDs de usuários permitidos; vazio significa todos os usuários |
+
+## Configuração inicial
+
+### Configuração rápida (recomendada)
+
+A Plataforma Aberta QQ oferece uma entrada de criação com um clique:
+
+1. Abra o [QQ Bot Quick Create](https://q.qq.com/qqbot/openclaw/index.html) e faça login escaneando o QR code
+2. O sistema cria o bot automaticamente — copie o **App ID** e o **App Secret**
+3. Preencha as credenciais no arquivo de configuração do PicoClaw
+4. Execute `picoclaw gateway` para iniciar o serviço
+5. Abra o QQ e comece a conversar com o bot
+
+> O App Secret é exibido apenas uma vez — salve-o imediatamente. Visualizá-lo novamente forçará uma redefinição.
+>
+> Bots criados pela entrada rápida são apenas para uso pessoal do criador e não suportam chats em grupo. Para suporte a grupos, configure o modo sandbox na [Plataforma Aberta QQ](https://q.qq.com/).
+
+### Configuração manual
+
+1. Faça login na [Plataforma Aberta QQ](https://q.qq.com/) com sua conta QQ e registre-se como desenvolvedor
+2. Crie um bot QQ e personalize seu avatar e nome
+3. Obtenha o **App ID** e o **App Secret** nas configurações do bot
+4. Preencha as credenciais no arquivo de configuração do PicoClaw
+5. Execute `picoclaw gateway` para iniciar o serviço
+6. Pesquise seu bot no QQ e comece a conversar
+
+> Durante o desenvolvimento, recomenda-se habilitar o modo sandbox e adicionar usuários e grupos de teste ao sandbox para depuração.
diff --git a/docs/channels/qq/README.vi.md b/docs/channels/qq/README.vi.md
new file mode 100644
index 000000000..1f3eb89da
--- /dev/null
+++ b/docs/channels/qq/README.vi.md
@@ -0,0 +1,54 @@
+> Quay lại [README](../../../README.vi.md)
+
+# QQ
+
+PicoClaw hỗ trợ QQ thông qua API Bot chính thức của Nền tảng Mở QQ.
+
+## Cấu hình
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Trường | Kiểu | Bắt buộc | Mô tả |
+| ---------- | ------ | -------- | ------------------------------------------------------------------------ |
+| enabled | bool | Có | Có bật kênh QQ hay không |
+| app_id | string | Có | App ID của ứng dụng bot QQ |
+| app_secret | string | Có | App Secret của ứng dụng bot QQ |
+| 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ả |
+
+## Hướng dẫn thiết lập
+
+### Thiết lập nhanh (Khuyến nghị)
+
+Nền tảng Mở QQ cung cấp lối vào tạo bot một chạm:
+
+1. Mở [QQ Bot Quick Create](https://q.qq.com/qqbot/openclaw/index.html) và đăng nhập bằng cách quét mã QR
+2. Hệ thống tự động tạo bot — sao chép **App ID** và **App Secret**
+3. Điền thông tin xác thực vào file cấu hình PicoClaw
+4. Chạy `picoclaw gateway` để khởi động dịch vụ
+5. Mở QQ và bắt đầu trò chuyện với bot
+
+> App Secret chỉ hiển thị một lần — hãy lưu lại ngay. Xem lại sẽ buộc phải đặt lại.
+>
+> Bot được tạo qua lối vào nhanh chỉ dành cho người tạo sử dụng cá nhân và chưa hỗ trợ chat nhóm. Để hỗ trợ chat nhóm, hãy cấu hình chế độ sandbox trên [Nền tảng Mở QQ](https://q.qq.com/).
+
+### Tạo thủ công
+
+1. Đăng nhập vào [Nền tảng Mở QQ](https://q.qq.com/) bằng tài khoản QQ và đăng ký tài khoản nhà phát triển
+2. Tạo bot QQ, tùy chỉnh ảnh đại diện và tên
+3. Lấy **App ID** và **App Secret** trong cài đặt bot
+4. Điền thông tin xác thực vào file cấu hình PicoClaw
+5. Chạy `picoclaw gateway` để khởi động dịch vụ
+6. Tìm kiếm bot của bạn trong QQ và bắt đầu trò chuyện
+
+> Trong giai đoạn phát triển, nên bật chế độ sandbox và thêm người dùng, nhóm thử nghiệm vào sandbox để gỡ lỗi.
diff --git a/docs/channels/qq/README.zh.md b/docs/channels/qq/README.zh.md
index 6211d2ec4..e7f6d2050 100644
--- a/docs/channels/qq/README.zh.md
+++ b/docs/channels/qq/README.zh.md
@@ -1,3 +1,5 @@
+> 返回 [README](../../../README.zh.md)
+
# QQ
PicoClaw 通过 QQ 开放平台的官方机器人 API 提供对 QQ 的支持。
@@ -28,7 +30,27 @@ PicoClaw 通过 QQ 开放平台的官方机器人 API 提供对 QQ 的支持。
## 设置流程
-1. 前往 [QQ 开放平台](https://q.qq.com/) 创建一个机器人
-2. 通过仪表盘获取 App ID 和 App Secret
-3. 开启机器人沙箱模式, 将用户和群添加到沙箱中
-4. 将 App ID 和 App Secret 填入配置文件中
+### 快捷方式(推荐)
+
+QQ 开放平台提供了一键创建入口:
+
+1. 打开 [QQ 机器人快速创建](https://q.qq.com/qqbot/openclaw/index.html),扫码登录
+2. 系统自动创建机器人,复制 **App ID** 和 **App Secret**
+3. 将凭证填入 PicoClaw 配置文件
+4. 运行 `picoclaw gateway` 启动服务
+5. 打开 QQ,与机器人开始对话
+
+> App Secret 仅显示一次,请立即保存。再次查看将强制重置。
+>
+> 通过快捷入口创建的机器人仅供创建人使用,暂不支持群聊。如需群聊功能,请在 [QQ 开放平台](https://q.qq.com/) 配置沙箱模式。
+
+### 手动创建
+
+1. 使用 QQ 账号登录 [QQ 开放平台](https://q.qq.com/),注册开发者账号
+2. 创建 QQ 机器人,自定义头像和名称
+3. 在机器人设置中获取 **App ID** 和 **App Secret**
+4. 将凭证填入 PicoClaw 配置文件
+5. 运行 `picoclaw gateway` 启动服务
+6. 在 QQ 中搜索你的机器人,开始对话
+
+> 开发阶段建议开启沙箱模式,将测试用户和群添加到沙箱中进行调试。
diff --git a/docs/channels/slack/README.fr.md b/docs/channels/slack/README.fr.md
new file mode 100644
index 000000000..81dcebdec
--- /dev/null
+++ b/docs/channels/slack/README.fr.md
@@ -0,0 +1,35 @@
+> Retour au [README](../../../README.fr.md)
+
+# Slack
+
+Slack est l'une des principales plateformes de messagerie instantanée pour les entreprises. PicoClaw utilise le Socket Mode de Slack pour une communication bidirectionnelle en temps réel, sans nécessiter la configuration d'un endpoint webhook public.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "slack": {
+ "enabled": true,
+ "bot_token": "xoxb-...",
+ "app_token": "xapp-...",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Champ | Type | Requis | Description |
+| ---------- | ------ | ------ | ---------------------------------------------------------------------------- |
+| enabled | bool | Oui | Activer ou non le canal Slack |
+| bot_token | string | Oui | Bot User OAuth Token du bot Slack (commence par xoxb-) |
+| app_token | string | Oui | App Level Token Socket Mode de l'application Slack (commence par xapp-) |
+| allow_from | array | Non | Liste blanche d'ID utilisateurs ; vide signifie tous les utilisateurs |
+
+## Procédure de configuration
+
+1. Rendez-vous sur [Slack API](https://api.slack.com/) et créez une nouvelle application Slack
+2. Activez le Socket Mode et obtenez l'App Level Token
+3. Ajoutez des Bot Token Scopes (par exemple `chat:write`, `im:history`, etc.)
+4. Installez l'application dans votre espace de travail et obtenez le Bot User OAuth Token
+5. Renseignez le Bot Token et l'App Token dans le fichier de configuration
diff --git a/docs/channels/slack/README.ja.md b/docs/channels/slack/README.ja.md
new file mode 100644
index 000000000..c8d268b9c
--- /dev/null
+++ b/docs/channels/slack/README.ja.md
@@ -0,0 +1,35 @@
+> [README](../../../README.ja.md) に戻る
+
+# Slack
+
+Slack は世界をリードする企業向けインスタントメッセージングプラットフォームです。PicoClaw は Slack の Socket Mode を使用してリアルタイムの双方向通信を実現しており、公開 Webhook エンドポイントの設定は不要です。
+
+## 設定
+
+```json
+{
+ "channels": {
+ "slack": {
+ "enabled": true,
+ "bot_token": "xoxb-...",
+ "app_token": "xapp-...",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| フィールド | 型 | 必須 | 説明 |
+| ---------- | ------ | ------ | ------------------------------------------------------------------------ |
+| enabled | bool | はい | Slack チャンネルを有効にするかどうか |
+| bot_token | string | はい | Slack ボットの Bot User OAuth Token(xoxb- で始まる) |
+| app_token | string | はい | Slack アプリの Socket Mode App Level Token(xapp- で始まる) |
+| allow_from | array | いいえ | ユーザーIDのホワイトリスト。空の場合は全ユーザーを許可 |
+
+## セットアップ手順
+
+1. [Slack API](https://api.slack.com/) にアクセスして新しい Slack アプリを作成する
+2. Socket Mode を有効にして App Level Token を取得する
+3. Bot Token Scopes を追加する(例: `chat:write`、`im:history` など)
+4. アプリをワークスペースにインストールして Bot User OAuth Token を取得する
+5. Bot Token と App Token を設定ファイルに入力する
diff --git a/docs/channels/slack/README.md b/docs/channels/slack/README.md
new file mode 100644
index 000000000..9d5aafab9
--- /dev/null
+++ b/docs/channels/slack/README.md
@@ -0,0 +1,35 @@
+> Back to [README](../../../README.md)
+
+# Slack
+
+Slack is a leading enterprise instant messaging platform. PicoClaw uses Slack's Socket Mode for real-time bidirectional communication, with no need to configure a public webhook endpoint.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "slack": {
+ "enabled": true,
+ "bot_token": "xoxb-...",
+ "app_token": "xapp-...",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Field | Type | Required | Description |
+| ---------- | ------ | -------- | ------------------------------------------------------------------------ |
+| enabled | bool | Yes | Whether to enable the Slack channel |
+| bot_token | string | Yes | Bot User OAuth Token for the Slack bot (starts with xoxb-) |
+| app_token | string | Yes | Socket Mode App Level Token for the Slack app (starts with xapp-) |
+| allow_from | array | No | User ID whitelist; empty means all users are allowed |
+
+## Setup
+
+1. Go to [Slack API](https://api.slack.com/) and create a new Slack app
+2. Enable Socket Mode and obtain the App Level Token
+3. Add Bot Token Scopes (e.g. `chat:write`, `im:history`, etc.)
+4. Install the app to your workspace and obtain the Bot User OAuth Token
+5. Fill in the Bot Token and App Token in the configuration file
diff --git a/docs/channels/slack/README.pt-br.md b/docs/channels/slack/README.pt-br.md
new file mode 100644
index 000000000..ea8a6c0fc
--- /dev/null
+++ b/docs/channels/slack/README.pt-br.md
@@ -0,0 +1,35 @@
+> Voltar ao [README](../../../README.pt-br.md)
+
+# Slack
+
+O Slack é uma das principais plataformas de mensagens instantâneas para empresas. O PicoClaw usa o Socket Mode do Slack para comunicação bidirecional em tempo real, sem necessidade de configurar um endpoint de webhook público.
+
+## Configuração
+
+```json
+{
+ "channels": {
+ "slack": {
+ "enabled": true,
+ "bot_token": "xoxb-...",
+ "app_token": "xapp-...",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Campo | Tipo | Obrigatório | Descrição |
+| ---------- | ------ | ----------- | ---------------------------------------------------------------------------- |
+| enabled | bool | Sim | Se o canal Slack deve ser habilitado |
+| bot_token | string | Sim | Bot User OAuth Token do bot Slack (começa com xoxb-) |
+| app_token | string | Sim | App Level Token do Socket Mode do aplicativo Slack (começa com xapp-) |
+| allow_from | array | Não | Lista de permissão de IDs de usuário; vazio permite todos |
+
+## Configuração passo a passo
+
+1. Acesse o [Slack API](https://api.slack.com/) e crie um novo aplicativo Slack
+2. Ative o Socket Mode e obtenha o App Level Token
+3. Adicione Bot Token Scopes (ex.: `chat:write`, `im:history`, etc.)
+4. Instale o aplicativo no seu workspace e obtenha o Bot User OAuth Token
+5. Preencha o Bot Token e o App Token no arquivo de configuração
diff --git a/docs/channels/slack/README.vi.md b/docs/channels/slack/README.vi.md
new file mode 100644
index 000000000..dae84728c
--- /dev/null
+++ b/docs/channels/slack/README.vi.md
@@ -0,0 +1,35 @@
+> Quay lại [README](../../../README.vi.md)
+
+# Slack
+
+Slack là nền tảng nhắn tin tức thì hàng đầu dành cho doanh nghiệp. PicoClaw sử dụng Socket Mode của Slack để giao tiếp hai chiều theo thời gian thực, không cần cấu hình endpoint webhook công khai.
+
+## Cấu hình
+
+```json
+{
+ "channels": {
+ "slack": {
+ "enabled": true,
+ "bot_token": "xoxb-...",
+ "app_token": "xapp-...",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Trường | Kiểu | Bắt buộc | Mô tả |
+| ---------- | ------ | -------- | ---------------------------------------------------------------------------- |
+| enabled | bool | Có | Có bật kênh Slack hay không |
+| bot_token | string | Có | Bot User OAuth Token của Slack bot (bắt đầu bằng xoxb-) |
+| app_token | string | Có | App Level Token Socket Mode của ứng dụng Slack (bắt đầu bằng xapp-) |
+| allow_from | array | Không | Danh sách trắng ID người dùng; để trống cho phép tất cả |
+
+## Quy trình thiết lập
+
+1. Truy cập [Slack API](https://api.slack.com/) và tạo một ứng dụng Slack mới
+2. Bật Socket Mode và lấy App Level Token
+3. Thêm Bot Token Scopes (ví dụ: `chat:write`, `im:history`, v.v.)
+4. Cài đặt ứng dụng vào workspace và lấy Bot User OAuth Token
+5. Điền Bot Token và App Token vào file cấu hình
diff --git a/docs/channels/slack/README.zh.md b/docs/channels/slack/README.zh.md
index 58ebcb566..884039162 100644
--- a/docs/channels/slack/README.zh.md
+++ b/docs/channels/slack/README.zh.md
@@ -1,3 +1,5 @@
+> 返回 [README](../../../README.zh.md)
+
# Slack
Slack 是全球领先的企业级即时通讯平台。PicoClaw 采用 Slack 的 Socket Mode 实现实时双向通信,无需配置公开的 Webhook 端点。
diff --git a/docs/channels/telegram/README.fr.md b/docs/channels/telegram/README.fr.md
new file mode 100644
index 000000000..d9ab0644f
--- /dev/null
+++ b/docs/channels/telegram/README.fr.md
@@ -0,0 +1,35 @@
+> Retour au [README](../../../README.fr.md)
+
+# Telegram
+
+Le canal Telegram utilise le long polling via l'API Bot Telegram pour une communication basée sur les bots. Il prend en charge les messages texte, les pièces jointes multimédias (photos, messages vocaux, audio, documents), la transcription vocale via Groq Whisper et la gestion des commandes intégrée.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
+ "allow_from": ["123456789"],
+ "proxy": ""
+ }
+ }
+}
+```
+
+| 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) |
+
+## Configuration initiale
+
+1. Rechercher `@BotFather` dans Telegram
+2. Envoyer la commande `/newbot` et suivre les instructions pour créer un nouveau bot
+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`)
diff --git a/docs/channels/telegram/README.ja.md b/docs/channels/telegram/README.ja.md
new file mode 100644
index 000000000..03c48cb64
--- /dev/null
+++ b/docs/channels/telegram/README.ja.md
@@ -0,0 +1,35 @@
+> [README](../../../README.ja.md) に戻る
+
+# Telegram
+
+Telegram チャンネルは、Telegram Bot API を使用したロングポーリングによるボットベースの通信を実装しています。テキストメッセージ、メディア添付ファイル(写真、音声、オーディオ、ドキュメント)、Groq Whisper による音声文字起こし、および組み込みコマンドハンドラーをサポートしています。
+
+## 設定
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
+ "allow_from": ["123456789"],
+ "proxy": ""
+ }
+ }
+}
+```
+
+| フィールド | 型 | 必須 | 説明 |
+| ---------- | ------ | ---- | ----------------------------------------------------------------- |
+| enabled | bool | はい | Telegram チャンネルを有効にするかどうか |
+| token | string | はい | Telegram Bot API トークン |
+| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 |
+| proxy | string | いいえ | Telegram API への接続に使用するプロキシ URL (例: http://127.0.0.1:7890) |
+
+## セットアップ手順
+
+1. Telegram で `@BotFather` を検索する
+2. `/newbot` コマンドを送信し、指示に従って新しいボットを作成する
+3. HTTP API トークンを取得する
+4. 設定ファイルにトークンを入力する
+5. (任意) `allow_from` を設定して、対話を許可するユーザー ID を制限する(ID は `@userinfobot` で取得可能)
diff --git a/docs/channels/telegram/README.md b/docs/channels/telegram/README.md
new file mode 100644
index 000000000..a3e057ba4
--- /dev/null
+++ b/docs/channels/telegram/README.md
@@ -0,0 +1,35 @@
+> Back to [README](../../../README.md)
+
+# Telegram
+
+The Telegram channel uses long polling via the Telegram Bot API for bot-based communication. It supports text messages, media attachments (photos, voice, audio, documents), voice transcription via Groq Whisper, and built-in command handling.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
+ "allow_from": ["123456789"],
+ "proxy": ""
+ }
+ }
+}
+```
+
+| 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) |
+
+## Setup
+
+1. Search for `@BotFather` in Telegram
+2. Send the `/newbot` command and follow the prompts to create a new bot
+3. Obtain the HTTP API Token
+4. Fill in the Token in the configuration file
+5. (Optional) Configure `allow_from` to restrict which user IDs can interact (you can get IDs via `@userinfobot`)
diff --git a/docs/channels/telegram/README.pt-br.md b/docs/channels/telegram/README.pt-br.md
new file mode 100644
index 000000000..8d2c935b4
--- /dev/null
+++ b/docs/channels/telegram/README.pt-br.md
@@ -0,0 +1,35 @@
+> Voltar ao [README](../../../README.pt-br.md)
+
+# Telegram
+
+O canal Telegram utiliza long polling via a API de Bot do Telegram para comunicação baseada em bots. Suporta mensagens de texto, anexos de mídia (fotos, voz, áudio, documentos), transcrição de voz via Groq Whisper e tratamento de comandos integrado.
+
+## Configuração
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
+ "allow_from": ["123456789"],
+ "proxy": ""
+ }
+ }
+}
+```
+
+| 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) |
+
+## Configuração inicial
+
+1. Pesquise por `@BotFather` no Telegram
+2. Envie o comando `/newbot` e siga as instruções para criar um novo bot
+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`)
diff --git a/docs/channels/telegram/README.vi.md b/docs/channels/telegram/README.vi.md
new file mode 100644
index 000000000..858a9fc41
--- /dev/null
+++ b/docs/channels/telegram/README.vi.md
@@ -0,0 +1,35 @@
+> Quay lại [README](../../../README.vi.md)
+
+# Telegram
+
+Kênh Telegram sử dụng long polling qua Telegram Bot API để giao tiếp dựa trên bot. Hỗ trợ tin nhắn văn bản, tệp đính kèm đa phương tiện (ảnh, giọng nói, âm thanh, tài liệu), chuyển giọng nói thành văn bản qua Groq Whisper và xử lý lệnh tích hợp sẵn.
+
+## Cấu hình
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
+ "allow_from": ["123456789"],
+ "proxy": ""
+ }
+ }
+}
+```
+
+| 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) |
+
+## Hướng dẫn thiết lập
+
+1. Tìm kiếm `@BotFather` trong Telegram
+2. Gửi lệnh `/newbot` và làm theo hướng dẫn để tạo bot mới
+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`)
diff --git a/docs/channels/telegram/README.zh.md b/docs/channels/telegram/README.zh.md
index d453c68fa..f50c712ce 100644
--- a/docs/channels/telegram/README.zh.md
+++ b/docs/channels/telegram/README.zh.md
@@ -1,3 +1,5 @@
+> 返回 [README](../../../README.zh.md)
+
# Telegram
Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、通过 Groq Whisper 进行语音转录以及内置命令处理器。
diff --git a/docs/channels/wecom/wecom_aibot/README.fr.md b/docs/channels/wecom/wecom_aibot/README.fr.md
new file mode 100644
index 000000000..8020dd7b0
--- /dev/null
+++ b/docs/channels/wecom/wecom_aibot/README.fr.md
@@ -0,0 +1,118 @@
+> Retour au [README](../../../../README.fr.md)
+
+# WeCom AI Bot
+
+Le WeCom AI Bot est une méthode d'intégration de conversation IA officiellement fournie par WeCom. Il prend en charge les conversations privées et de groupe, intègre un protocole de réponse en streaming et supporte l'envoi proactif de la réponse finale via `response_url` en cas de dépassement de délai.
+
+## Comparaison avec les autres canaux WeCom
+
+| Fonctionnalité | WeCom Bot | WeCom App | **WeCom AI Bot** |
+|----------------|-----------|-----------|-----------------|
+| Chat privé | ✅ | ✅ | ✅ |
+| Chat de groupe | ✅ | ❌ | ✅ |
+| Sortie en streaming | ❌ | ❌ | ✅ |
+| Push proactif en cas de timeout | ❌ | ✅ | ✅ |
+| Complexité de configuration | Faible | Élevée | Moyenne |
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "wecom_aibot": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
+ "webhook_path": "/webhook/wecom-aibot",
+ "allow_from": [],
+ "welcome_message": "你好!有什么可以帮助你的吗?",
+ "max_steps": 10
+ }
+ }
+}
+```
+
+| Champ | Type | Requis | Description |
+| ---------------- | ------ | ------ | -------------------------------------------------- |
+| token | string | Oui | Jeton de vérification du callback, configuré sur la page de gestion de l'AI Bot |
+| encoding_aes_key | string | Oui | Clé AES de 43 caractères, générée aléatoirement sur la page de gestion de l'AI Bot |
+| webhook_path | string | Non | Chemin du webhook (par défaut : /webhook/wecom-aibot) |
+| allow_from | array | Non | Liste blanche d'ID utilisateurs ; un tableau vide autorise tous les utilisateurs |
+| welcome_message | string | Non | Message de bienvenue envoyé à l'ouverture du chat ; laisser vide pour désactiver |
+| reply_timeout | int | Non | Délai de réponse en secondes (par défaut : 5) |
+| max_steps | int | Non | Nombre maximum d'étapes d'exécution de l'agent (par défaut : 10) |
+
+## Procédure de configuration
+
+1. Connectez-vous à la [console d'administration WeCom](https://work.weixin.qq.com/wework_admin)
+2. Accédez à « Gestion des applications » → « AI Bot », puis créez ou sélectionnez un AI Bot
+3. Sur la page de configuration de l'AI Bot, renseignez les informations de « Réception des messages » :
+ - **URL** : `http://:18790/webhook/wecom-aibot`
+ - **Token** : Généré aléatoirement ou personnalisé
+ - **EncodingAESKey** : Cliquez sur « Générer aléatoirement » pour obtenir une clé de 43 caractères
+4. Saisissez le Token et l'EncodingAESKey dans le fichier de configuration PicoClaw, démarrez le service, puis revenez à la console d'administration pour enregistrer (WeCom enverra une requête de vérification)
+
+> [!TIP]
+> Le serveur doit être accessible par les serveurs WeCom. Si vous êtes sur un intranet ou en développement local, utilisez [ngrok](https://ngrok.com) ou frp pour le tunneling.
+
+## Protocole de réponse en streaming
+
+Le WeCom AI Bot utilise un protocole de « pull en streaming », différent de la réponse unique d'un webhook standard :
+
+```
+L'utilisateur envoie un message
+ │
+ ▼
+PicoClaw retourne immédiatement {finish: false} (l'agent commence le traitement)
+ │
+ ▼
+WeCom effectue un pull environ toutes les 1 seconde avec {msgtype: "stream", stream: {id: "..."}}
+ │
+ ├─ Agent non terminé → retourne {finish: false} (continuer à attendre)
+ │
+ └─ Agent terminé → retourne {finish: true, content: "contenu de la réponse"}
+```
+
+**Gestion du timeout** (tâche dépassant 30 secondes) :
+
+Si le traitement de l'agent dépasse environ 30 secondes (la fenêtre de polling maximale de WeCom est de 6 minutes), PicoClaw va :
+
+1. Fermer immédiatement le stream et afficher à l'utilisateur : « ⏳ 正在处理中,请稍候,结果将稍后发送。 »
+2. L'agent continue de s'exécuter en arrière-plan
+3. Une fois l'agent terminé, la réponse finale est envoyée proactivement à l'utilisateur via le `response_url` inclus dans le message
+
+> `response_url` est émis par WeCom, valable 1 heure, utilisable une seule fois, sans chiffrement requis — il suffit de POSTer directement le corps du message markdown.
+
+## Message de bienvenue
+
+Lorsque `welcome_message` est configuré, PicoClaw répond automatiquement avec ce message lorsqu'un utilisateur ouvre la fenêtre de chat avec l'AI Bot (événement `enter_chat`). Laisser vide pour ignorer silencieusement.
+
+```json
+"welcome_message": "你好!我是 PicoClaw AI 助手,有什么可以帮你?"
+```
+
+## FAQ
+
+### Échec de la vérification de l'URL de callback
+
+- Vérifiez que le pare-feu du serveur autorise le port concerné (par défaut 18790)
+- Vérifiez que `token` et `encoding_aes_key` sont correctement renseignés
+- Consultez les logs PicoClaw pour voir si une requête GET de WeCom a été reçue
+
+### Les messages ne reçoivent pas de réponse
+
+- Vérifiez que `allow_from` ne restreint pas accidentellement l'expéditeur
+- Recherchez `context canceled` ou des erreurs d'agent dans les logs
+- Vérifiez que la configuration de l'agent (ex. `model_name`) est correcte
+
+### Pas de push final reçu pour les tâches longues
+
+- Vérifiez que le callback du message inclut `response_url` (uniquement supporté par la nouvelle version du WeCom AI Bot)
+- Vérifiez que le serveur peut effectuer des requêtes sortantes (nécessite un POST vers `response_url`)
+- Consultez les logs pour les mots-clés `response_url mode` et `Sending reply via response_url`
+
+## Références
+
+- [Documentation d'intégration WeCom AI Bot](https://developer.work.weixin.qq.com/document/path/100719)
+- [Description du protocole de réponse en streaming](https://developer.work.weixin.qq.com/document/path/100719)
+- [Réponse proactive via response_url](https://developer.work.weixin.qq.com/document/path/101138)
diff --git a/docs/channels/wecom/wecom_aibot/README.ja.md b/docs/channels/wecom/wecom_aibot/README.ja.md
new file mode 100644
index 000000000..210caffb4
--- /dev/null
+++ b/docs/channels/wecom/wecom_aibot/README.ja.md
@@ -0,0 +1,118 @@
+> [README](../../../../README.ja.md) に戻る
+
+# 企業WeChat AIボット
+
+企業WeChat AIボット(AI Bot)は、企業WeChatが公式に提供するAI会話連携方式です。プライベートチャットとグループチャットの両方をサポートし、ストリーミングレスポンスプロトコルを内蔵しており、タイムアウト後に `response_url` を通じて最終返信をプッシュする機能もサポートしています。
+
+## 他のWeCom チャンネルとの比較
+
+| 機能 | WeCom Bot | WeCom App | **WeCom AI Bot** |
+|------|-----------|-----------|-----------------|
+| プライベートチャット | ✅ | ✅ | ✅ |
+| グループチャット | ✅ | ❌ | ✅ |
+| ストリーミング出力 | ❌ | ❌ | ✅ |
+| タイムアウト時のプッシュ | ❌ | ✅ | ✅ |
+| 設定の複雑さ | 低 | 高 | 中 |
+
+## 設定
+
+```json
+{
+ "channels": {
+ "wecom_aibot": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
+ "webhook_path": "/webhook/wecom-aibot",
+ "allow_from": [],
+ "welcome_message": "你好!有什么可以帮助你的吗?",
+ "max_steps": 10
+ }
+ }
+}
+```
+
+| フィールド | 型 | 必須 | 説明 |
+| ---------------- | ------ | ---- | -------------------------------------------------- |
+| token | string | はい | コールバック検証トークン。AIボット管理ページで設定 |
+| encoding_aes_key | string | はい | 43文字のAESキー。AIボット管理ページでランダム生成 |
+| webhook_path | string | いいえ | Webhookパス(デフォルト:/webhook/wecom-aibot) |
+| allow_from | array | いいえ | ユーザーIDの許可リスト。空配列は全ユーザーを許可 |
+| welcome_message | string | いいえ | ユーザーがチャットを開いたときに送信するウェルカムメッセージ。空白の場合は送信しない |
+| reply_timeout | int | いいえ | 返信タイムアウト(秒、デフォルト:5) |
+| max_steps | int | いいえ | エージェントの最大実行ステップ数(デフォルト:10) |
+
+## セットアップ手順
+
+1. [企業WeChat管理コンソール](https://work.weixin.qq.com/wework_admin) にログイン
+2. 「アプリ管理」→「AIボット」に進み、AIボットを作成または選択
+3. AIボット設定ページで「メッセージ受信」情報を入力:
+ - **URL**:`http://:18790/webhook/wecom-aibot`
+ - **Token**:ランダム生成またはカスタム
+ - **EncodingAESKey**:「ランダム生成」をクリックして43文字のキーを取得
+4. TokenとEncodingAESKeyをPicoClawの設定ファイルに入力し、サービスを起動してから管理コンソールに戻って保存(企業WeChatが検証リクエストを送信します)
+
+> [!TIP]
+> サーバーは企業WeChatのサーバーからアクセス可能である必要があります。イントラネットやローカル開発環境の場合は、[ngrok](https://ngrok.com) またはfrpを使用してトンネリングしてください。
+
+## ストリーミングレスポンスプロトコル
+
+WeCom AIボットは「ストリーミングプル」プロトコルを使用しており、通常のWebhookの一回限りの返信とは異なります:
+
+```
+ユーザーがメッセージを送信
+ │
+ ▼
+PicoClawが即座に {finish: false} を返す(エージェントが処理開始)
+ │
+ ▼
+企業WeChatが約1秒ごとに {msgtype: "stream", stream: {id: "..."}} でプル
+ │
+ ├─ エージェント未完了 → {finish: false} を返す(待機継続)
+ │
+ └─ エージェント完了 → {finish: true, content: "返信内容"} を返す
+```
+
+**タイムアウト処理**(タスクが30秒を超える場合):
+
+エージェントの処理時間が約30秒を超えた場合(企業WeChatの最大ポーリングウィンドウは6分)、PicoClawは:
+
+1. 即座にストリームを閉じ、ユーザーに「⏳ 正在处理中,请稍候,结果将稍后发送。」と表示
+2. エージェントはバックグラウンドで処理を継続
+3. エージェント完了後、メッセージに含まれる `response_url` を通じて最終返信をユーザーにプッシュ
+
+> `response_url` は企業WeChatが発行し、有効期限は1時間、使用は1回限りで、暗号化不要。マークダウンメッセージ本文をそのままPOSTするだけです。
+
+## ウェルカムメッセージ
+
+`welcome_message` を設定すると、ユーザーがAIボットとのチャットウィンドウを開いたとき(`enter_chat` イベント)に、PicoClawが自動的にそのメッセージを返信します。空白の場合は無視されます。
+
+```json
+"welcome_message": "你好!我是 PicoClaw AI 助手,有什么可以帮你?"
+```
+
+## よくある質問
+
+### コールバックURL検証の失敗
+
+- サーバーのファイアウォールで該当ポートが開放されているか確認(デフォルト18790)
+- `token` と `encoding_aes_key` が正しく入力されているか確認
+- PicoClawのログに企業WeChatからのGETリクエストが届いているか確認
+
+### メッセージに返信がない
+
+- `allow_from` が誤って送信者を制限していないか確認
+- ログに `context canceled` またはエージェントエラーが出ていないか確認
+- エージェント設定(`model_name` など)が正しいか確認
+
+### 長時間タスクで最終プッシュが届かない
+
+- メッセージコールバックに `response_url` が含まれているか確認(新バージョンの企業WeChat AIボットのみ対応)
+- サーバーが外部ネットワークへのアウトバウンドリクエストを送信できるか確認(`response_url` へのPOSTが必要)
+- ログのキーワード `response_url mode` と `Sending reply via response_url` を確認
+
+## 参考ドキュメント
+
+- [企業WeChat AIボット連携ドキュメント](https://developer.work.weixin.qq.com/document/path/100719)
+- [ストリーミングレスポンスプロトコルの説明](https://developer.work.weixin.qq.com/document/path/100719)
+- [response_url によるプロアクティブ返信](https://developer.work.weixin.qq.com/document/path/101138)
diff --git a/docs/channels/wecom/wecom_aibot/README.md b/docs/channels/wecom/wecom_aibot/README.md
new file mode 100644
index 000000000..31d831617
--- /dev/null
+++ b/docs/channels/wecom/wecom_aibot/README.md
@@ -0,0 +1,118 @@
+> Back to [README](../../../../README.md)
+
+# WeCom AI Bot
+
+The WeCom AI Bot is an official AI conversation integration provided by WeCom. It supports both private and group chats, has a built-in streaming response protocol, and supports proactively pushing the final reply via `response_url` after a timeout.
+
+## Comparison with Other WeCom Channels
+
+| Feature | WeCom Bot | WeCom App | **WeCom AI Bot** |
+|---------|-----------|-----------|-----------------|
+| Private Chat | ✅ | ✅ | ✅ |
+| Group Chat | ✅ | ❌ | ✅ |
+| Streaming Output | ❌ | ❌ | ✅ |
+| Proactive Push on Timeout | ❌ | ✅ | ✅ |
+| Configuration Complexity | Low | High | Medium |
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "wecom_aibot": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
+ "webhook_path": "/webhook/wecom-aibot",
+ "allow_from": [],
+ "welcome_message": "你好!有什么可以帮助你的吗?",
+ "max_steps": 10
+ }
+ }
+}
+```
+
+| Field | Type | Required | Description |
+| ---------------- | ------ | -------- | -------------------------------------------------- |
+| token | string | Yes | Callback verification token, configured on the AI Bot management page |
+| encoding_aes_key | string | Yes | 43-character AES key, randomly generated on the AI Bot management page |
+| webhook_path | string | No | Webhook path (default: /webhook/wecom-aibot) |
+| allow_from | array | No | User ID allowlist; empty array allows all users |
+| welcome_message | string | No | Welcome message sent when a user opens the chat; leave empty to disable |
+| reply_timeout | int | No | Reply timeout in seconds (default: 5) |
+| max_steps | int | No | Maximum agent execution steps (default: 10) |
+
+## Setup
+
+1. Log in to the [WeCom Admin Console](https://work.weixin.qq.com/wework_admin)
+2. Go to "App Management" → "AI Bot", then create or select an AI Bot
+3. On the AI Bot configuration page, fill in the "Message Reception" details:
+ - **URL**: `http://:18790/webhook/wecom-aibot`
+ - **Token**: Randomly generated or custom
+ - **EncodingAESKey**: Click "Random Generate" to get a 43-character key
+4. Enter the Token and EncodingAESKey into the PicoClaw config file, start the service, then return to the admin console to save (WeCom will send a verification request)
+
+> [!TIP]
+> The server must be accessible by WeCom's servers. If you are on an intranet or developing locally, use [ngrok](https://ngrok.com) or frp for tunneling.
+
+## Streaming Response Protocol
+
+WeCom AI Bot uses a "streaming pull" protocol, which differs from the one-shot reply of a standard webhook:
+
+```
+User sends a message
+ │
+ ▼
+PicoClaw immediately returns {finish: false} (Agent starts processing)
+ │
+ ▼
+WeCom pulls approximately every 1 second with {msgtype: "stream", stream: {id: "..."}}
+ │
+ ├─ Agent not done → returns {finish: false} (keep waiting)
+ │
+ └─ Agent done → returns {finish: true, content: "reply content"}
+```
+
+**Timeout Handling** (task exceeds 30 seconds):
+
+If the Agent takes longer than approximately 30 seconds (WeCom's maximum polling window is 6 minutes), PicoClaw will:
+
+1. Immediately close the stream and show the user: "⏳ 正在处理中,请稍候,结果将稍后发送。"
+2. The Agent continues running in the background
+3. Once the Agent finishes, the final reply is proactively pushed to the user via the `response_url` included in the message
+
+> `response_url` is issued by WeCom, valid for 1 hour, can only be used once, requires no encryption — just POST the markdown message body directly.
+
+## Welcome Message
+
+When `welcome_message` is configured, PicoClaw will automatically reply with it when a user opens the chat window with the AI Bot (`enter_chat` event). Leave it empty to silently ignore the event.
+
+```json
+"welcome_message": "你好!我是 PicoClaw AI 助手,有什么可以帮你?"
+```
+
+## FAQ
+
+### Callback URL Verification Failed
+
+- Confirm the server firewall has the relevant port open (default 18790)
+- Confirm `token` and `encoding_aes_key` are entered correctly
+- Check PicoClaw logs to see if a GET request from WeCom was received
+
+### Messages Not Getting a Reply
+
+- Check whether `allow_from` is accidentally restricting the sender
+- Look for `context canceled` or Agent errors in the logs
+- Confirm the Agent configuration (e.g., `model_name`) is correct
+
+### No Final Push Received for Long-Running Tasks
+
+- Confirm the message callback includes `response_url` (only supported by the newer WeCom AI Bot)
+- Confirm the server can make outbound requests (needs to POST to `response_url`)
+- Check logs for keywords `response_url mode` and `Sending reply via response_url`
+
+## Reference
+
+- [WeCom AI Bot Integration Docs](https://developer.work.weixin.qq.com/document/path/100719)
+- [Streaming Response Protocol](https://developer.work.weixin.qq.com/document/path/100719)
+- [Proactive Reply via response_url](https://developer.work.weixin.qq.com/document/path/101138)
diff --git a/docs/channels/wecom/wecom_aibot/README.pt-br.md b/docs/channels/wecom/wecom_aibot/README.pt-br.md
new file mode 100644
index 000000000..1ab735c41
--- /dev/null
+++ b/docs/channels/wecom/wecom_aibot/README.pt-br.md
@@ -0,0 +1,118 @@
+> Voltar ao [README](../../../../README.pt-br.md)
+
+# WeCom AI Bot
+
+O WeCom AI Bot é uma forma oficial de integração de conversas com IA fornecida pelo WeCom. Suporta conversas privadas e em grupo, possui um protocolo de resposta em streaming integrado e suporta o envio proativo da resposta final via `response_url` após um timeout.
+
+## Comparação com outros canais WeCom
+
+| Recurso | WeCom Bot | WeCom App | **WeCom AI Bot** |
+|---------|-----------|-----------|-----------------|
+| Chat privado | ✅ | ✅ | ✅ |
+| Chat em grupo | ✅ | ❌ | ✅ |
+| Saída em streaming | ❌ | ❌ | ✅ |
+| Push proativo em timeout | ❌ | ✅ | ✅ |
+| Complexidade de configuração | Baixa | Alta | Média |
+
+## Configuração
+
+```json
+{
+ "channels": {
+ "wecom_aibot": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
+ "webhook_path": "/webhook/wecom-aibot",
+ "allow_from": [],
+ "welcome_message": "你好!有什么可以帮助你的吗?",
+ "max_steps": 10
+ }
+ }
+}
+```
+
+| Campo | Tipo | Obrigatório | Descrição |
+| ---------------- | ------ | ----------- | -------------------------------------------------- |
+| token | string | Sim | Token de verificação de callback, configurado na página de gerenciamento do AI Bot |
+| encoding_aes_key | string | Sim | Chave AES de 43 caracteres, gerada aleatoriamente na página de gerenciamento do AI Bot |
+| webhook_path | string | Não | Caminho do webhook (padrão: /webhook/wecom-aibot) |
+| allow_from | array | Não | Lista de permissão de IDs de usuários; array vazio permite todos os usuários |
+| welcome_message | string | Não | Mensagem de boas-vindas enviada quando o usuário abre o chat; deixe vazio para desativar |
+| reply_timeout | int | Não | Timeout de resposta em segundos (padrão: 5) |
+| max_steps | int | Não | Número máximo de etapas de execução do agente (padrão: 10) |
+
+## Configuração passo a passo
+
+1. Faça login no [Console de Administração do WeCom](https://work.weixin.qq.com/wework_admin)
+2. Acesse "Gerenciamento de Apps" → "AI Bot", depois crie ou selecione um AI Bot
+3. Na página de configuração do AI Bot, preencha as informações de "Recebimento de Mensagens":
+ - **URL**: `http://:18790/webhook/wecom-aibot`
+ - **Token**: Gerado aleatoriamente ou personalizado
+ - **EncodingAESKey**: Clique em "Gerar Aleatoriamente" para obter uma chave de 43 caracteres
+4. Insira o Token e o EncodingAESKey no arquivo de configuração do PicoClaw, inicie o serviço e volte ao console de administração para salvar (o WeCom enviará uma requisição de verificação)
+
+> [!TIP]
+> O servidor precisa ser acessível pelos servidores do WeCom. Se estiver em uma intranet ou desenvolvendo localmente, use [ngrok](https://ngrok.com) ou frp para tunelamento.
+
+## Protocolo de resposta em streaming
+
+O WeCom AI Bot usa um protocolo de "pull em streaming", diferente da resposta única de um webhook padrão:
+
+```
+Usuário envia uma mensagem
+ │
+ ▼
+PicoClaw retorna imediatamente {finish: false} (Agente começa a processar)
+ │
+ ▼
+WeCom faz pull aproximadamente a cada 1 segundo com {msgtype: "stream", stream: {id: "..."}}
+ │
+ ├─ Agente não concluído → retorna {finish: false} (continuar aguardando)
+ │
+ └─ Agente concluído → retorna {finish: true, content: "conteúdo da resposta"}
+```
+
+**Tratamento de timeout** (tarefa excede 30 segundos):
+
+Se o processamento do agente demorar mais de aproximadamente 30 segundos (a janela máxima de polling do WeCom é de 6 minutos), o PicoClaw irá:
+
+1. Fechar imediatamente o stream e exibir ao usuário: "⏳ 正在处理中,请稍候,结果将稍后发送。"
+2. O agente continua executando em segundo plano
+3. Após a conclusão do agente, a resposta final é enviada proativamente ao usuário via `response_url` incluído na mensagem
+
+> `response_url` é emitido pelo WeCom, válido por 1 hora, pode ser usado apenas uma vez, sem necessidade de criptografia — basta fazer um POST com o corpo da mensagem em markdown diretamente.
+
+## Mensagem de boas-vindas
+
+Quando `welcome_message` está configurado, o PicoClaw responde automaticamente com essa mensagem quando um usuário abre a janela de chat com o AI Bot (evento `enter_chat`). Deixe vazio para ignorar silenciosamente.
+
+```json
+"welcome_message": "你好!我是 PicoClaw AI 助手,有什么可以帮你?"
+```
+
+## Perguntas frequentes
+
+### Falha na verificação da URL de callback
+
+- Confirme que o firewall do servidor tem a porta correspondente aberta (padrão 18790)
+- Confirme que `token` e `encoding_aes_key` estão preenchidos corretamente
+- Verifique os logs do PicoClaw para ver se uma requisição GET do WeCom foi recebida
+
+### Mensagens sem resposta
+
+- Verifique se `allow_from` está restringindo acidentalmente o remetente
+- Procure por `context canceled` ou erros do agente nos logs
+- Confirme que a configuração do agente (ex.: `model_name`) está correta
+
+### Nenhum push final recebido para tarefas longas
+
+- Confirme que o callback da mensagem inclui `response_url` (suportado apenas pelo novo WeCom AI Bot)
+- Confirme que o servidor consegue fazer requisições de saída (precisa fazer POST para `response_url`)
+- Verifique nos logs as palavras-chave `response_url mode` e `Sending reply via response_url`
+
+## Referências
+
+- [Documentação de integração do WeCom AI Bot](https://developer.work.weixin.qq.com/document/path/100719)
+- [Descrição do protocolo de resposta em streaming](https://developer.work.weixin.qq.com/document/path/100719)
+- [Resposta proativa via response_url](https://developer.work.weixin.qq.com/document/path/101138)
diff --git a/docs/channels/wecom/wecom_aibot/README.vi.md b/docs/channels/wecom/wecom_aibot/README.vi.md
new file mode 100644
index 000000000..cb6586e6e
--- /dev/null
+++ b/docs/channels/wecom/wecom_aibot/README.vi.md
@@ -0,0 +1,118 @@
+> Quay lại [README](../../../../README.vi.md)
+
+# WeCom AI Bot
+
+WeCom AI Bot là phương thức tích hợp hội thoại AI chính thức do WeCom cung cấp. Hỗ trợ cả chat riêng tư và chat nhóm, tích hợp giao thức phản hồi streaming, và hỗ trợ chủ động đẩy phản hồi cuối cùng qua `response_url` sau khi hết thời gian chờ.
+
+## So sánh với các kênh WeCom khác
+
+| Tính năng | WeCom Bot | WeCom App | **WeCom AI Bot** |
+|-----------|-----------|-----------|-----------------|
+| Chat riêng tư | ✅ | ✅ | ✅ |
+| Chat nhóm | ✅ | ❌ | ✅ |
+| Đầu ra streaming | ❌ | ❌ | ✅ |
+| Đẩy chủ động khi timeout | ❌ | ✅ | ✅ |
+| Độ phức tạp cấu hình | Thấp | Cao | Trung bình |
+
+## Cấu hình
+
+```json
+{
+ "channels": {
+ "wecom_aibot": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
+ "webhook_path": "/webhook/wecom-aibot",
+ "allow_from": [],
+ "welcome_message": "你好!有什么可以帮助你的吗?",
+ "max_steps": 10
+ }
+ }
+}
+```
+
+| Trường | Kiểu | Bắt buộc | Mô tả |
+| ---------------- | ------ | --------- | -------------------------------------------------- |
+| token | string | Có | Token xác minh callback, cấu hình trên trang quản lý AI Bot |
+| encoding_aes_key | string | Có | Khóa AES 43 ký tự, được tạo ngẫu nhiên trên trang quản lý AI Bot |
+| webhook_path | string | Không | Đường dẫn webhook (mặc định: /webhook/wecom-aibot) |
+| allow_from | array | Không | Danh sách cho phép ID người dùng; mảng rỗng cho phép tất cả người dùng |
+| welcome_message | string | Không | Tin nhắn chào mừng gửi khi người dùng mở chat; để trống để tắt |
+| reply_timeout | int | Không | Thời gian chờ phản hồi tính bằng giây (mặc định: 5) |
+| max_steps | int | Không | Số bước thực thi tối đa của agent (mặc định: 10) |
+
+## Hướng dẫn thiết lập
+
+1. Đăng nhập vào [Bảng điều khiển quản trị WeCom](https://work.weixin.qq.com/wework_admin)
+2. Vào "Quản lý ứng dụng" → "AI Bot", sau đó tạo hoặc chọn một AI Bot
+3. Trên trang cấu hình AI Bot, điền thông tin "Nhận tin nhắn":
+ - **URL**: `http://:18790/webhook/wecom-aibot`
+ - **Token**: Tạo ngẫu nhiên hoặc tùy chỉnh
+ - **EncodingAESKey**: Nhấp "Tạo ngẫu nhiên" để lấy khóa 43 ký tự
+4. Nhập Token và EncodingAESKey vào file cấu hình PicoClaw, khởi động dịch vụ rồi quay lại bảng điều khiển quản trị để lưu (WeCom sẽ gửi yêu cầu xác minh)
+
+> [!TIP]
+> Máy chủ cần có thể truy cập được từ các máy chủ WeCom. Nếu bạn đang ở mạng nội bộ hoặc phát triển cục bộ, hãy sử dụng [ngrok](https://ngrok.com) hoặc frp để tạo tunnel.
+
+## Giao thức phản hồi streaming
+
+WeCom AI Bot sử dụng giao thức "pull streaming", khác với phản hồi một lần của webhook thông thường:
+
+```
+Người dùng gửi tin nhắn
+ │
+ ▼
+PicoClaw trả về ngay {finish: false} (Agent bắt đầu xử lý)
+ │
+ ▼
+WeCom pull khoảng mỗi 1 giây với {msgtype: "stream", stream: {id: "..."}}
+ │
+ ├─ Agent chưa xong → trả về {finish: false} (tiếp tục chờ)
+ │
+ └─ Agent xong → trả về {finish: true, content: "nội dung phản hồi"}
+```
+
+**Xử lý timeout** (tác vụ vượt quá 30 giây):
+
+Nếu thời gian xử lý của agent vượt quá khoảng 30 giây (cửa sổ polling tối đa của WeCom là 6 phút), PicoClaw sẽ:
+
+1. Đóng stream ngay lập tức và hiển thị cho người dùng: "⏳ 正在处理中,请稍候,结果将稍后发送。"
+2. Agent tiếp tục chạy ở nền
+3. Sau khi agent hoàn thành, phản hồi cuối cùng được chủ động đẩy đến người dùng qua `response_url` có trong tin nhắn
+
+> `response_url` do WeCom cấp, có hiệu lực 1 giờ, chỉ dùng được một lần, không cần mã hóa — chỉ cần POST trực tiếp nội dung tin nhắn markdown.
+
+## Tin nhắn chào mừng
+
+Khi `welcome_message` được cấu hình, PicoClaw sẽ tự động phản hồi bằng tin nhắn đó khi người dùng mở cửa sổ chat với AI Bot (sự kiện `enter_chat`). Để trống để bỏ qua im lặng.
+
+```json
+"welcome_message": "你好!我是 PicoClaw AI 助手,有什么可以帮你?"
+```
+
+## Câu hỏi thường gặp
+
+### Xác minh URL callback thất bại
+
+- Xác nhận tường lửa máy chủ đã mở cổng tương ứng (mặc định 18790)
+- Xác nhận `token` và `encoding_aes_key` được điền đúng
+- Kiểm tra log PicoClaw xem có nhận được yêu cầu GET từ WeCom không
+
+### Tin nhắn không nhận được phản hồi
+
+- Kiểm tra xem `allow_from` có vô tình hạn chế người gửi không
+- Tìm `context canceled` hoặc lỗi agent trong log
+- Xác nhận cấu hình agent (ví dụ: `model_name`) là đúng
+
+### Không nhận được push cuối cùng cho tác vụ dài
+
+- Xác nhận callback tin nhắn có chứa `response_url` (chỉ hỗ trợ bởi WeCom AI Bot phiên bản mới)
+- Xác nhận máy chủ có thể thực hiện yêu cầu ra ngoài (cần POST đến `response_url`)
+- Kiểm tra log với từ khóa `response_url mode` và `Sending reply via response_url`
+
+## Tài liệu tham khảo
+
+- [Tài liệu tích hợp WeCom AI Bot](https://developer.work.weixin.qq.com/document/path/100719)
+- [Mô tả giao thức phản hồi streaming](https://developer.work.weixin.qq.com/document/path/100719)
+- [Phản hồi chủ động qua response_url](https://developer.work.weixin.qq.com/document/path/101138)
diff --git a/docs/channels/wecom/wecom_aibot/README.zh.md b/docs/channels/wecom/wecom_aibot/README.zh.md
index 48a151a25..9da5ee1b9 100644
--- a/docs/channels/wecom/wecom_aibot/README.zh.md
+++ b/docs/channels/wecom/wecom_aibot/README.zh.md
@@ -1,3 +1,5 @@
+> 返回 [README](../../../../README.zh.md)
+
# 企业微信智能机器人 (AI Bot)
企业微信智能机器人(AI Bot)是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议。PicoClaw 当前同时支持两种接入模式:
@@ -97,7 +99,7 @@
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)
2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot
3. 在 AI Bot 配置页面,填写"消息接收"信息:
- - **URL**:`http://:18791/webhook/wecom-aibot`
+ - **URL**:`http://:18790/webhook/wecom-aibot`
- **Token**:随机生成或自定义
- **EncodingAESKey**:点击"随机生成",得到 43 字符密钥
4. 将 Token 和 EncodingAESKey 填入 PicoClaw 配置文件,启动服务后回到管理后台保存
@@ -159,6 +161,7 @@ PicoClaw 立即返回 {finish: false}(Agent 开始处理)
### 回调 URL 验证失败
+
- 确认 `token` 与 `encoding_aes_key` 填写正确
- 确认服务器防火墙已开放对应端口
- 检查 PicoClaw 日志是否收到了来自企业微信的验证请求
diff --git a/docs/channels/wecom/wecom_app/README.fr.md b/docs/channels/wecom/wecom_app/README.fr.md
new file mode 100644
index 000000000..f95426497
--- /dev/null
+++ b/docs/channels/wecom/wecom_app/README.fr.md
@@ -0,0 +1,47 @@
+> Retour au [README](../../../../README.fr.md)
+
+# Application interne WeCom
+
+Une application interne WeCom est une application créée par une entreprise au sein de WeCom, principalement destinée à un usage interne. Grâce aux applications internes WeCom, les entreprises peuvent assurer une communication et une collaboration efficaces avec leurs employés, améliorant ainsi la productivité.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "wecom_app": {
+ "enabled": true,
+ "corp_id": "wwxxxxxxxxxxxxxxxx",
+ "corp_secret": "YOUR_CORP_SECRET",
+ "agent_id": 1000002,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+| Champ | Type | Requis | Description |
+| ---------------- | ------ | ------ | ---------------------------------------- |
+| corp_id | string | Oui | ID de l'entreprise |
+| corp_secret | string | Oui | Secret de l'application |
+| agent_id | int | Oui | ID de l'agent de l'application |
+| token | string | Oui | Jeton de vérification du callback |
+| encoding_aes_key | string | Oui | Clé AES de 43 caractères |
+| webhook_path | string | Non | Chemin du webhook (par défaut : /webhook/wecom-app) |
+| allow_from | array | Non | Liste blanche d'ID utilisateurs |
+| reply_timeout | int | Non | Délai de réponse en secondes |
+
+## Procédure de configuration
+
+1. Connectez-vous à la [console d'administration WeCom](https://work.weixin.qq.com/)
+2. Accédez à « Gestion des applications » -> « Créer une application »
+3. Obtenez l'ID d'entreprise (CorpID) et le Secret de l'application
+4. Configurez « Réception des messages » dans les paramètres de l'application pour obtenir le Token et l'EncodingAESKey
+5. Définissez l'URL de callback sur `http://:/webhook/wecom-app`
+6. Saisissez le CorpID, le Secret, l'AgentID et les autres informations dans le fichier de configuration
+
+ Remarque : PicoClaw utilise désormais un serveur HTTP Gateway partagé pour recevoir les callbacks webhook de tous les canaux. L'adresse d'écoute par défaut est 127.0.0.1:18790. Pour recevoir des callbacks depuis l'internet public, configurez un reverse proxy de votre domaine externe vers le Gateway (port par défaut 18790).
diff --git a/docs/channels/wecom/wecom_app/README.ja.md b/docs/channels/wecom/wecom_app/README.ja.md
new file mode 100644
index 000000000..4bd5a7101
--- /dev/null
+++ b/docs/channels/wecom/wecom_app/README.ja.md
@@ -0,0 +1,47 @@
+> [README](../../../../README.ja.md) に戻る
+
+# 企業WeChat 自社開発アプリ
+
+企業WeChat 自社開発アプリとは、企業が企業WeChat内で作成するアプリケーションで、主に社内利用を目的としています。企業WeChat 自社開発アプリを通じて、企業は従業員との効率的なコミュニケーションと協業を実現し、業務効率を向上させることができます。
+
+## 設定
+
+```json
+{
+ "channels": {
+ "wecom_app": {
+ "enabled": true,
+ "corp_id": "wwxxxxxxxxxxxxxxxx",
+ "corp_secret": "YOUR_CORP_SECRET",
+ "agent_id": 1000002,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+| フィールド | 型 | 必須 | 説明 |
+| ---------------- | ------ | ---- | ---------------------------------------- |
+| corp_id | string | はい | 企業ID |
+| corp_secret | string | はい | アプリケーションシークレット |
+| agent_id | int | はい | アプリケーションエージェントID |
+| token | string | はい | コールバック検証トークン |
+| encoding_aes_key | string | はい | 43文字のAESキー |
+| webhook_path | string | いいえ | Webhookパス(デフォルト:/webhook/wecom-app) |
+| allow_from | array | いいえ | ユーザーIDの許可リスト |
+| reply_timeout | int | いいえ | 返信タイムアウト(秒) |
+
+## セットアップ手順
+
+1. [企業WeChat管理コンソール](https://work.weixin.qq.com/) にログイン
+2. 「アプリ管理」→「アプリを作成」に進む
+3. 企業ID(CorpID)とアプリのSecretを取得
+4. アプリ設定で「メッセージ受信」を設定し、TokenとEncodingAESKeyを取得
+5. コールバックURLを `http://:/webhook/wecom-app` に設定
+6. CorpID、Secret、AgentIDなどの情報を設定ファイルに入力
+
+ 注意:PicoClawは現在、すべてのチャンネルのwebhookコールバックを受信するために共有のGateway HTTPサーバーを使用しています。デフォルトのリスニングアドレスは127.0.0.1:18790です。公共インターネットからコールバックを受信するには、外部ドメインをGateway(デフォルトポート18790)にリバースプロキシしてください。
diff --git a/docs/channels/wecom/wecom_app/README.md b/docs/channels/wecom/wecom_app/README.md
new file mode 100644
index 000000000..4397f805a
--- /dev/null
+++ b/docs/channels/wecom/wecom_app/README.md
@@ -0,0 +1,47 @@
+> Back to [README](../../../../README.md)
+
+# WeCom Internal App
+
+A WeCom Internal App is an application created by an enterprise within WeCom, primarily intended for internal use. Through WeCom Internal Apps, enterprises can achieve efficient communication and collaboration with employees, improving productivity.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "wecom_app": {
+ "enabled": true,
+ "corp_id": "wwxxxxxxxxxxxxxxxx",
+ "corp_secret": "YOUR_CORP_SECRET",
+ "agent_id": 1000002,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+| Field | Type | Required | Description |
+| ---------------- | ------ | -------- | ---------------------------------------- |
+| corp_id | string | Yes | Enterprise ID |
+| corp_secret | string | Yes | Application secret |
+| agent_id | int | Yes | Application agent ID |
+| token | string | Yes | Callback verification token |
+| encoding_aes_key | string | Yes | 43-character AES key |
+| webhook_path | string | No | Webhook path (default: /webhook/wecom-app) |
+| allow_from | array | No | User ID allowlist |
+| reply_timeout | int | No | Reply timeout in seconds |
+
+## Setup
+
+1. Log in to the [WeCom Admin Console](https://work.weixin.qq.com/)
+2. Go to "App Management" -> "Create App"
+3. Obtain the Enterprise ID (CorpID) and App Secret
+4. Configure "Receive Messages" in the app settings to get the Token and EncodingAESKey
+5. Set the callback URL to `http://:/webhook/wecom-app`
+6. Enter the CorpID, Secret, AgentID, and other details into the config file
+
+ Note: PicoClaw now uses a shared Gateway HTTP server to receive webhook callbacks for all channels. The default listening address is 127.0.0.1:18790. To receive callbacks from the public internet, reverse-proxy your external domain to the Gateway (default port 18790).
diff --git a/docs/channels/wecom/wecom_app/README.pt-br.md b/docs/channels/wecom/wecom_app/README.pt-br.md
new file mode 100644
index 000000000..bd0538ed0
--- /dev/null
+++ b/docs/channels/wecom/wecom_app/README.pt-br.md
@@ -0,0 +1,47 @@
+> Voltar ao [README](../../../../README.pt-br.md)
+
+# App Interno WeCom
+
+Um App Interno WeCom é um aplicativo criado por uma empresa dentro do WeCom, destinado principalmente ao uso interno. Por meio dos Apps Internos WeCom, as empresas podem alcançar comunicação e colaboração eficientes com os funcionários, melhorando a produtividade.
+
+## Configuração
+
+```json
+{
+ "channels": {
+ "wecom_app": {
+ "enabled": true,
+ "corp_id": "wwxxxxxxxxxxxxxxxx",
+ "corp_secret": "YOUR_CORP_SECRET",
+ "agent_id": 1000002,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+| Campo | Tipo | Obrigatório | Descrição |
+| ---------------- | ------ | ----------- | ---------------------------------------- |
+| corp_id | string | Sim | ID da empresa |
+| corp_secret | string | Sim | Segredo da aplicação |
+| agent_id | int | Sim | ID do agente da aplicação |
+| token | string | Sim | Token de verificação de callback |
+| encoding_aes_key | string | Sim | Chave AES de 43 caracteres |
+| webhook_path | string | Não | Caminho do webhook (padrão: /webhook/wecom-app) |
+| allow_from | array | Não | Lista de permissão de IDs de usuários |
+| reply_timeout | int | Não | Timeout de resposta em segundos |
+
+## Configuração passo a passo
+
+1. Faça login no [Console de Administração do WeCom](https://work.weixin.qq.com/)
+2. Acesse "Gerenciamento de Apps" -> "Criar App"
+3. Obtenha o ID da Empresa (CorpID) e o Secret do App
+4. Configure "Receber Mensagens" nas configurações do app para obter o Token e o EncodingAESKey
+5. Defina a URL de callback como `http://:/webhook/wecom-app`
+6. Insira o CorpID, Secret, AgentID e outras informações no arquivo de configuração
+
+ Nota: O PicoClaw agora usa um servidor HTTP Gateway compartilhado para receber callbacks de webhook de todos os canais. O endereço de escuta padrão é 127.0.0.1:18790. Para receber callbacks da internet pública, configure um reverse proxy do seu domínio externo para o Gateway (porta padrão 18790).
diff --git a/docs/channels/wecom/wecom_app/README.vi.md b/docs/channels/wecom/wecom_app/README.vi.md
new file mode 100644
index 000000000..f713f9501
--- /dev/null
+++ b/docs/channels/wecom/wecom_app/README.vi.md
@@ -0,0 +1,47 @@
+> Quay lại [README](../../../../README.vi.md)
+
+# Ứng dụng nội bộ WeCom
+
+Ứng dụng nội bộ WeCom là ứng dụng được doanh nghiệp tạo ra trong WeCom, chủ yếu dùng cho mục đích nội bộ. Thông qua ứng dụng nội bộ WeCom, doanh nghiệp có thể thực hiện giao tiếp và cộng tác hiệu quả với nhân viên, nâng cao hiệu suất làm việc.
+
+## Cấu hình
+
+```json
+{
+ "channels": {
+ "wecom_app": {
+ "enabled": true,
+ "corp_id": "wwxxxxxxxxxxxxxxxx",
+ "corp_secret": "YOUR_CORP_SECRET",
+ "agent_id": 1000002,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+| Trường | Kiểu | Bắt buộc | Mô tả |
+| ---------------- | ------ | --------- | ---------------------------------------- |
+| corp_id | string | Có | ID doanh nghiệp |
+| corp_secret | string | Có | Secret của ứng dụng |
+| agent_id | int | Có | ID agent của ứng dụng |
+| token | string | Có | Token xác minh callback |
+| encoding_aes_key | string | Có | Khóa AES 43 ký tự |
+| webhook_path | string | Không | Đường dẫn webhook (mặc định: /webhook/wecom-app) |
+| allow_from | array | Không | Danh sách cho phép ID người dùng |
+| reply_timeout | int | Không | Thời gian chờ phản hồi tính bằng giây |
+
+## Hướng dẫn thiết lập
+
+1. Đăng nhập vào [Bảng điều khiển quản trị WeCom](https://work.weixin.qq.com/)
+2. Vào "Quản lý ứng dụng" -> "Tạo ứng dụng"
+3. Lấy ID doanh nghiệp (CorpID) và Secret của ứng dụng
+4. Cấu hình "Nhận tin nhắn" trong cài đặt ứng dụng để lấy Token và EncodingAESKey
+5. Đặt URL callback thành `http://:/webhook/wecom-app`
+6. Nhập CorpID, Secret, AgentID và các thông tin khác vào file cấu hình
+
+ Lưu ý: PicoClaw hiện sử dụng máy chủ HTTP Gateway dùng chung để nhận callback webhook cho tất cả các kênh. Địa chỉ lắng nghe mặc định là 127.0.0.1:18790. Để nhận callback từ internet công cộng, hãy cấu hình reverse proxy từ tên miền bên ngoài của bạn đến Gateway (cổng mặc định 18790).
diff --git a/docs/channels/wecom/wecom_app/README.zh.md b/docs/channels/wecom/wecom_app/README.zh.md
index 0a9858107..81268692d 100644
--- a/docs/channels/wecom/wecom_app/README.zh.md
+++ b/docs/channels/wecom/wecom_app/README.zh.md
@@ -1,3 +1,5 @@
+> 返回 [README](../../../../README.zh.md)
+
# 企业微信自建应用
企业微信自建应用是指企业在企业微信中创建的应用,主要用于企业内部使用。通过企业微信自建应用,企业可以实现与员工的高效沟通和协作,提高工作效率。
diff --git a/docs/channels/wecom/wecom_bot/README.fr.md b/docs/channels/wecom/wecom_bot/README.fr.md
new file mode 100644
index 000000000..fa3caeb37
--- /dev/null
+++ b/docs/channels/wecom/wecom_bot/README.fr.md
@@ -0,0 +1,41 @@
+> Retour au [README](../../../../README.fr.md)
+
+# WeCom Bot
+
+Le WeCom Bot est une méthode d'intégration rapide fournie par WeCom, permettant de recevoir des messages via une URL Webhook.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "wecom": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
+ "webhook_path": "/webhook/wecom",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+| Champ | Type | Requis | Description |
+| ---------------- | ------ | ------ | -------------------------------------------- |
+| token | string | Oui | Jeton de vérification de signature |
+| encoding_aes_key | string | Oui | Clé AES de 43 caractères utilisée pour le déchiffrement |
+| webhook_url | string | Oui | URL Webhook du bot de groupe WeCom utilisée pour envoyer les réponses |
+| webhook_path | string | Non | Chemin de l'endpoint webhook (par défaut : /webhook/wecom) |
+| allow_from | array | Non | Liste blanche d'ID utilisateurs (vide = autoriser tous les utilisateurs) |
+| reply_timeout | int | Non | Délai de réponse en secondes (par défaut : 5) |
+
+## Procédure de configuration
+
+1. Ajouter un bot à un groupe WeCom
+2. Obtenir l'URL Webhook
+3. (Pour recevoir des messages) Configurer l'adresse API de réception des messages (URL de callback), le Token et l'EncodingAESKey sur la page de configuration du bot
+4. Saisir les informations pertinentes dans le fichier de configuration
+
+ Remarque : PicoClaw utilise désormais un serveur HTTP Gateway partagé pour recevoir les callbacks webhook de tous les canaux. L'adresse d'écoute par défaut est 127.0.0.1:18790. Pour recevoir des callbacks depuis l'internet public, configurez un reverse proxy de votre domaine externe vers le Gateway (port par défaut 18790).
diff --git a/docs/channels/wecom/wecom_bot/README.ja.md b/docs/channels/wecom/wecom_bot/README.ja.md
new file mode 100644
index 000000000..c932c6b4f
--- /dev/null
+++ b/docs/channels/wecom/wecom_bot/README.ja.md
@@ -0,0 +1,41 @@
+> [README](../../../../README.ja.md) に戻る
+
+# 企業WeChat ボット
+
+企業WeChat ボットは、企業WeChatが提供するWebhook URLを通じてメッセージを受信できる迅速な連携方式です。
+
+## 設定
+
+```json
+{
+ "channels": {
+ "wecom": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
+ "webhook_path": "/webhook/wecom",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+| フィールド | 型 | 必須 | 説明 |
+| ---------------- | ------ | ---- | -------------------------------------------- |
+| token | string | はい | 署名検証トークン |
+| encoding_aes_key | string | はい | 復号化に使用する43文字のAESキー |
+| webhook_url | string | はい | 返信送信に使用する企業WeChatグループボットのWebhook URL |
+| webhook_path | string | いいえ | Webhookエンドポイントパス(デフォルト:/webhook/wecom) |
+| allow_from | array | いいえ | ユーザーIDの許可リスト(空 = 全ユーザーを許可) |
+| reply_timeout | int | いいえ | 返信タイムアウト(秒、デフォルト:5) |
+
+## セットアップ手順
+
+1. 企業WeChatグループにボットを追加
+2. Webhook URLを取得
+3. (メッセージを受信する場合)ボット設定ページでメッセージ受信APIアドレス(コールバックURL)、Token、EncodingAESKeyを設定
+4. 関連情報を設定ファイルに入力
+
+ 注意:PicoClawは現在、すべてのチャンネルのwebhookコールバックを受信するために共有のGateway HTTPサーバーを使用しています。デフォルトのリスニングアドレスは127.0.0.1:18790です。公共インターネットからコールバックを受信するには、外部ドメインをGateway(デフォルトポート18790)にリバースプロキシしてください。
diff --git a/docs/channels/wecom/wecom_bot/README.md b/docs/channels/wecom/wecom_bot/README.md
new file mode 100644
index 000000000..2600a6a6b
--- /dev/null
+++ b/docs/channels/wecom/wecom_bot/README.md
@@ -0,0 +1,41 @@
+> Back to [README](../../../../README.md)
+
+# WeCom Bot
+
+WeCom Bot is a quick integration method provided by WeCom that can receive messages via a Webhook URL.
+
+## Configuration
+
+```json
+{
+ "channels": {
+ "wecom": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
+ "webhook_path": "/webhook/wecom",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+| Field | Type | Required | Description |
+| ---------------- | ------ | -------- | -------------------------------------------- |
+| token | string | Yes | Signature verification token |
+| encoding_aes_key | string | Yes | 43-character AES key used for decryption |
+| webhook_url | string | Yes | WeCom group bot webhook URL used to send replies |
+| webhook_path | string | No | Webhook endpoint path (default: /webhook/wecom) |
+| allow_from | array | No | User ID allowlist (empty = allow all users) |
+| reply_timeout | int | No | Reply timeout in seconds (default: 5) |
+
+## Setup
+
+1. Add a bot to a WeCom group
+2. Obtain the Webhook URL
+3. (To receive messages) Configure the message receiving API address (callback URL), Token, and EncodingAESKey on the bot configuration page
+4. Enter the relevant information into the config file
+
+ Note: PicoClaw now uses a shared Gateway HTTP server to receive webhook callbacks for all channels. The default listening address is 127.0.0.1:18790. To receive callbacks from the public internet, reverse-proxy your external domain to the Gateway (default port 18790).
diff --git a/docs/channels/wecom/wecom_bot/README.pt-br.md b/docs/channels/wecom/wecom_bot/README.pt-br.md
new file mode 100644
index 000000000..4b3af1404
--- /dev/null
+++ b/docs/channels/wecom/wecom_bot/README.pt-br.md
@@ -0,0 +1,41 @@
+> Voltar ao [README](../../../../README.pt-br.md)
+
+# WeCom Bot
+
+O WeCom Bot é um método de integração rápida fornecido pelo WeCom que pode receber mensagens via URL de Webhook.
+
+## Configuração
+
+```json
+{
+ "channels": {
+ "wecom": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
+ "webhook_path": "/webhook/wecom",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+| Campo | Tipo | Obrigatório | Descrição |
+| ---------------- | ------ | ----------- | -------------------------------------------- |
+| token | string | Sim | Token de verificação de assinatura |
+| encoding_aes_key | string | Sim | Chave AES de 43 caracteres usada para descriptografia |
+| webhook_url | string | Sim | URL do webhook do bot de grupo WeCom usada para enviar respostas |
+| webhook_path | string | Não | Caminho do endpoint webhook (padrão: /webhook/wecom) |
+| allow_from | array | Não | Lista de permissão de IDs de usuários (vazio = permitir todos) |
+| reply_timeout | int | Não | Timeout de resposta em segundos (padrão: 5) |
+
+## Configuração passo a passo
+
+1. Adicione um bot a um grupo WeCom
+2. Obtenha a URL do Webhook
+3. (Para receber mensagens) Configure o endereço da API de recebimento de mensagens (URL de callback), Token e EncodingAESKey na página de configuração do bot
+4. Insira as informações relevantes no arquivo de configuração
+
+ Nota: O PicoClaw agora usa um servidor HTTP Gateway compartilhado para receber callbacks de webhook de todos os canais. O endereço de escuta padrão é 127.0.0.1:18790. Para receber callbacks da internet pública, configure um reverse proxy do seu domínio externo para o Gateway (porta padrão 18790).
diff --git a/docs/channels/wecom/wecom_bot/README.vi.md b/docs/channels/wecom/wecom_bot/README.vi.md
new file mode 100644
index 000000000..aab4b46cd
--- /dev/null
+++ b/docs/channels/wecom/wecom_bot/README.vi.md
@@ -0,0 +1,41 @@
+> Quay lại [README](../../../../README.vi.md)
+
+# WeCom Bot
+
+WeCom Bot là phương thức tích hợp nhanh do WeCom cung cấp, có thể nhận tin nhắn qua URL Webhook.
+
+## Cấu hình
+
+```json
+{
+ "channels": {
+ "wecom": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
+ "webhook_path": "/webhook/wecom",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+| Trường | Kiểu | Bắt buộc | Mô tả |
+| ---------------- | ------ | --------- | -------------------------------------------- |
+| token | string | Có | Token xác minh chữ ký |
+| encoding_aes_key | string | Có | Khóa AES 43 ký tự dùng để giải mã |
+| webhook_url | string | Có | URL webhook của bot nhóm WeCom dùng để gửi phản hồi |
+| webhook_path | string | Không | Đường dẫn endpoint webhook (mặc định: /webhook/wecom) |
+| allow_from | array | Không | Danh sách cho phép ID người dùng (rỗng = cho phép tất cả) |
+| reply_timeout | int | Không | Thời gian chờ phản hồi tính bằng giây (mặc định: 5) |
+
+## Hướng dẫn thiết lập
+
+1. Thêm bot vào một nhóm WeCom
+2. Lấy URL Webhook
+3. (Để nhận tin nhắn) Cấu hình địa chỉ API nhận tin nhắn (URL callback), Token và EncodingAESKey trên trang cấu hình bot
+4. Nhập thông tin liên quan vào file cấu hình
+
+ Lưu ý: PicoClaw hiện sử dụng máy chủ HTTP Gateway dùng chung để nhận callback webhook cho tất cả các kênh. Địa chỉ lắng nghe mặc định là 127.0.0.1:18790. Để nhận callback từ internet công cộng, hãy cấu hình reverse proxy từ tên miền bên ngoài của bạn đến Gateway (cổng mặc định 18790).
diff --git a/docs/channels/wecom/wecom_bot/README.zh.md b/docs/channels/wecom/wecom_bot/README.zh.md
index 63d9b84d6..016fcf973 100644
--- a/docs/channels/wecom/wecom_bot/README.zh.md
+++ b/docs/channels/wecom/wecom_bot/README.zh.md
@@ -1,3 +1,5 @@
+> 返回 [README](../../../../README.zh.md)
+
# 企业微信机器人
企业微信机器人是企业微信提供的一种快速接入方式,可以通过 Webhook URL 接收消息。
diff --git a/docs/chat-apps.md b/docs/chat-apps.md
index 66aa7ea53..3ed37e814 100644
--- a/docs/chat-apps.md
+++ b/docs/chat-apps.md
@@ -8,22 +8,22 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
-| Channel | Setup |
-| ------------ | ---------------------------------- |
-| **Telegram** | Easy (just a token) |
-| **Discord** | Easy (bot token + intents) |
-| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
-| **Matrix** | Medium (homeserver + bot access token) |
-| **QQ** | Easy (AppID + AppSecret) |
-| **DingTalk** | Medium (app credentials) |
-| **LINE** | Medium (credentials + webhook URL) |
-| **WeCom AI Bot** | Medium (Token + AES key) |
-| **Feishu** | Medium (App ID + Secret, WebSocket mode) |
-| **Slack** | Medium (Bot token + App token) |
-| **IRC** | Medium (server + TLS config) |
-| **OneBot** | Medium (QQ via OneBot protocol) |
-| **MaixCam** | Easy (Sipeed hardware integration) |
-| **Pico** | Native PicoClaw protocol |
+| Channel | Difficulty | Description | Documentation |
+| -------------------- | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
+| **Telegram** | ⭐ Easy | Recommended, voice-to-text, long polling (no public IP needed) | [Docs](../channels/telegram/README.md) |
+| **Discord** | ⭐ Easy | Socket Mode, group/DM support, rich bot ecosystem | [Docs](../channels/discord/README.md) |
+| **WhatsApp** | ⭐ Easy | Native (QR scan) or Bridge URL | [Docs](#whatsapp) |
+| **Slack** | ⭐ Easy | **Socket Mode** (no public IP needed), enterprise | [Docs](../channels/slack/README.md) |
+| **Matrix** | ⭐⭐ Medium | Federated protocol, self-hosting supported | [Docs](../channels/matrix/README.md) |
+| **QQ** | ⭐⭐ Medium | Official bot API, Chinese community | [Docs](../channels/qq/README.md) |
+| **DingTalk** | ⭐⭐ Medium | Stream mode (no public IP needed), enterprise | [Docs](../channels/dingtalk/README.md) |
+| **LINE** | ⭐⭐⭐ Advanced | HTTPS Webhook required | [Docs](../channels/line/README.md) |
+| **WeCom (企业微信)** | ⭐⭐⭐ Advanced | Group Bot (Webhook), custom App (API), AI Bot | [Bot](../channels/wecom/wecom_bot/README.md) / [App](../channels/wecom/wecom_app/README.md) / [AI Bot](../channels/wecom/wecom_aibot/README.md) |
+| **Feishu (飞书)** | ⭐⭐⭐ Advanced | Enterprise collaboration, feature-rich | [Docs](../channels/feishu/README.md) |
+| **IRC** | ⭐⭐ Medium | Server + TLS configuration | - |
+| **OneBot** | ⭐⭐ Medium | NapCat/Go-CQHTTP compatible, community ecosystem | [Docs](../channels/onebot/README.md) |
+| **MaixCam** | ⭐ Easy | Hardware integration channel for Sipeed AI cameras | [Docs](../channels/maixcam/README.md) |
+| **Pico** | ⭐ Easy | Native PicoClaw protocol channel | |
Telegram (Recommended)
@@ -172,12 +172,13 @@ If `session_store_path` is empty, the session is stored in `/whatsapp
QQ
-**1. Create a bot**
+**Quick setup (recommended)**
-- Go to [QQ Open Platform](https://q.qq.com/#)
-- Create an application → Get **AppID** and **AppSecret**
+QQ Open Platform provides a one-click setup page for OpenClaw-compatible bots:
-**2. Configure**
+1. Open [QQ Bot Quick Start](https://q.qq.com/qqbot/openclaw/index.html) and scan the QR code to log in
+2. A bot is created automatically — copy the **App ID** and **App Secret**
+3. Configure PicoClaw:
```json
{
@@ -192,13 +193,20 @@ If `session_store_path` is empty, the session is stored in `/whatsapp
}
```
-> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access.
+4. Run `picoclaw gateway` and open QQ to chat with your bot
-**3. Run**
+> The App Secret is only shown once. Save it immediately — viewing it again will force a reset.
+>
+> Bots created via the quick setup page are initially for the creator only and do not support group chats. To enable group access, configure sandbox mode on the [QQ Open Platform](https://q.qq.com/).
-```bash
-picoclaw gateway
-```
+**Manual setup**
+
+If you prefer to create the bot manually:
+
+* Log in at [QQ Open Platform](https://q.qq.com/) to register as a developer
+* Create a QQ bot — customize its avatar and name
+* Copy the **App ID** and **App Secret** from the bot settings
+* Configure as shown above and run `picoclaw gateway`
@@ -265,7 +273,7 @@ picoclaw gateway
picoclaw gateway
```
-For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](docs/channels/matrix/README.md).
+For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](channels/matrix/README.md).
@@ -326,7 +334,7 @@ PicoClaw supports three types of WeCom integration:
**Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only
**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat
-See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions.
+See [WeCom AI Bot Configuration Guide](channels/wecom/wecom_aibot/README.md) for detailed setup instructions.
**Quick Setup - WeCom Bot:**
@@ -400,7 +408,7 @@ picoclaw gateway
**1. Create an AI Bot**
* Go to WeCom Admin Console → App Management → AI Bot
-* In the AI Bot settings, configure callback URL: `http://your-server:18791/webhook/wecom-aibot`
+* In the AI Bot settings, configure callback URL: `http://your-server:18790/webhook/wecom-aibot`
* Copy **Token** and click "Random Generate" for **EncodingAESKey**
**2. Configure**
@@ -430,3 +438,148 @@ picoclaw gateway
> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.
+
+
+Feishu (Lark)
+
+PicoClaw connects to Feishu via WebSocket/SDK mode — no public webhook URL or callback server needed.
+
+**1. Create an app**
+
+* Go to [Feishu Open Platform](https://open.feishu.cn/) and create an application
+* In the app settings, enable the **Bot** capability
+* Create a version and publish the app (the app must be published to take effect)
+* Copy the **App ID** (starts with `cli_`) and **App Secret**
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "feishu": {
+ "enabled": true,
+ "app_id": "cli_xxx",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+Optional fields: `encrypt_key` and `verification_token` for event encryption (recommended for production).
+
+**3. Run and chat**
+
+```bash
+picoclaw gateway
+```
+
+Open Feishu, search for your bot name, and start chatting. You can also add the bot to a group — use `group_trigger.mention_only: true` to only respond when @mentioned.
+
+For full options, see [Feishu Channel Configuration Guide](channels/feishu/README.md).
+
+
+
+
+Slack
+
+**1. Create a Slack app**
+
+* Go to [Slack API](https://api.slack.com/apps) and create a new app
+* Under **OAuth & Permissions**, add bot scopes: `chat:write`, `app_mentions:read`, `im:history`, `im:read`, `im:write`
+* Install the app to your workspace
+* Copy the **Bot Token** (`xoxb-...`) and **App-Level Token** (`xapp-...`, enable Socket Mode to get this)
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "slack": {
+ "enabled": true,
+ "bot_token": "xoxb-YOUR-BOT-TOKEN",
+ "app_token": "xapp-YOUR-APP-TOKEN",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**3. Run**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+IRC
+
+**1. Configure**
+
+```json
+{
+ "channels": {
+ "irc": {
+ "enabled": true,
+ "server": "irc.libera.chat:6697",
+ "tls": true,
+ "nick": "picoclaw-bot",
+ "channels": ["#your-channel"],
+ "password": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+Optional: `nickserv_password` for NickServ authentication, `sasl_user`/`sasl_password` for SASL auth.
+
+**2. Run**
+
+```bash
+picoclaw gateway
+```
+
+The bot will connect to the IRC server and join the specified channels.
+
+
+
+
+OneBot (QQ via OneBot protocol)
+
+OneBot is an open protocol for QQ bots. PicoClaw connects to any OneBot v11 compatible implementation (e.g., [Lagrange](https://github.com/LagrangeDev/Lagrange.Core), [NapCat](https://github.com/NapNeko/NapCatQQ)) via WebSocket.
+
+**1. Set up a OneBot implementation**
+
+Install and run a OneBot v11 compatible QQ bot framework. Enable its WebSocket server.
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "onebot": {
+ "enabled": true,
+ "ws_url": "ws://127.0.0.1:8080",
+ "access_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Field | Description |
+|-------|-------------|
+| `ws_url` | WebSocket URL of the OneBot implementation |
+| `access_token` | Access token for authentication (if configured in OneBot) |
+| `reconnect_interval` | Reconnect interval in seconds (default: 5) |
+
+**3. Run**
+
+```bash
+picoclaw gateway
+```
+
+
diff --git a/docs/configuration.md b/docs/configuration.md
index 268de9135..b5d652a85 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -57,7 +57,7 @@ By default, skills are loaded from:
1. `~/.picoclaw/workspace/skills` (workspace)
2. `~/.picoclaw/skills` (global)
-3. `/skills` (builtin)
+3. `/skills` (builtin, set at build time)
For advanced/test setups, you can override the builtin skills root with:
diff --git a/docs/credential_encryption.md b/docs/credential_encryption.md
index 448eaaa10..dde8c782c 100644
--- a/docs/credential_encryption.md
+++ b/docs/credential_encryption.md
@@ -30,8 +30,9 @@ enc://AAAA...base64...
"model_list": [
{
"model_name": "gpt-4o",
+ "model": "openai/gpt-4o",
"api_key": "enc://AAAA...base64...",
- "base_url": "https://api.openai.com/v1"
+ "api_base": "https://api.openai.com/v1"
}
]
}
@@ -54,20 +55,12 @@ enc://AAAA...base64...
### Key Derivation
-Encryption uses **HKDF-SHA256** with an optional SSH private key as a second factor.
+Encryption uses **HKDF-SHA256** with an SSH private key as a second factor.
```
-Without SSH key (passphrase only):
-
- ikm = SHA256(passphrase)
- aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes)
-
-
-With SSH key (recommended):
-
- sshHash = SHA256(ssh_private_key_file_bytes)
- ikm = HMAC-SHA256(key=sshHash, message=passphrase)
- aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes)
+sshHash = SHA256(ssh_private_key_file_bytes)
+ikm = HMAC-SHA256(key=sshHash, message=passphrase)
+aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes)
```
### Encryption
@@ -125,7 +118,7 @@ This means a leaked config file alone is not sufficient to recover the API key,
| Variable | Required | Description |
|----------|----------|-------------|
| `PICOCLAW_KEY_PASSPHRASE` | Yes (for `enc://`) | Passphrase used for key derivation |
-| `PICOCLAW_SSH_KEY_PATH` | No | Path to SSH private key. Set to `""` to disable auto-detection and use passphrase-only mode |
+| `PICOCLAW_SSH_KEY_PATH` | No | Path to SSH private key. If not set, auto-detects from `~/.ssh/picoclaw_ed25519.key` |
### SSH Key Auto-Detection
@@ -140,11 +133,7 @@ Run `picoclaw onboard` to generate it automatically.
`os.UserHomeDir()` is used for cross-platform home directory resolution (reads `USERPROFILE` on Windows, `HOME` on Unix/macOS).
-To explicitly disable SSH key usage and use passphrase-only mode:
-
-```bash
-export PICOCLAW_SSH_KEY_PATH=""
-```
+> **Note:** An SSH key file is required for credential encryption. If no key is found and `PICOCLAW_SSH_KEY_PATH` is not set, encryption/decryption will fail. Run `picoclaw onboard` to generate the key automatically.
---
@@ -162,7 +151,7 @@ No re-encryption is needed.
## Security Considerations
-- **Passphrase strength matters in passphrase-only mode.** Without an SSH key, a weak passphrase can be brute-forced offline. Use `PICOCLAW_SSH_KEY_PATH=""` only in environments where no SSH key is available and the passphrase is sufficiently strong (≥ 32 random characters).
+- **Both passphrase and SSH key are required.** The SSH key acts as a second factor — without it, encryption/decryption will fail. Run `picoclaw onboard` to generate the key if it doesn't exist.
- **The SSH key is read-only at runtime.** PicoClaw never writes to or modifies the SSH key file.
- **Plaintext keys remain supported.** Existing configs without `enc://` are unaffected.
- **The `enc://` format is versioned** via the HKDF `info` field (`picoclaw-credential-v1`), allowing future algorithm upgrades without breaking existing encrypted values.
diff --git a/docs/docker.md b/docs/docker.md
index b91a7f68d..f868d4a42 100644
--- a/docs/docker.md
+++ b/docs/docker.md
@@ -12,6 +12,7 @@ git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
# 2. First run — auto-generates docker/data/config.json then exits
+# (only triggers when both config.json and workspace/ are missing)
docker compose -f docker/docker-compose.yml --profile gateway up
# The container prints "First-run setup complete." and stops.
diff --git a/docs/fr/ANTIGRAVITY_AUTH.md b/docs/fr/ANTIGRAVITY_AUTH.md
new file mode 100644
index 000000000..6cadf5238
--- /dev/null
+++ b/docs/fr/ANTIGRAVITY_AUTH.md
@@ -0,0 +1,809 @@
+> Retour au [README](../../README.fr.md)
+
+# Guide d'authentification et d'intégration Antigravity
+
+## Aperçu
+
+**Antigravity** (Google Cloud Code Assist) est un fournisseur de modèles IA soutenu par Google qui offre l'accès à des modèles tels que Claude Opus 4.6 et Gemini via l'infrastructure cloud de Google. Ce document fournit un guide complet sur le fonctionnement de l'authentification, la récupération des modèles et l'implémentation d'un nouveau fournisseur dans PicoClaw.
+
+---
+
+## Table des matières
+
+1. [Flux d'authentification](#flux-dauthentification)
+2. [Détails de l'implémentation OAuth](#détails-de-limplémentation-oauth)
+3. [Gestion des jetons](#gestion-des-jetons)
+4. [Récupération de la liste des modèles](#récupération-de-la-liste-des-modèles)
+5. [Suivi de l'utilisation](#suivi-de-lutilisation)
+6. [Structure du plugin fournisseur](#structure-du-plugin-fournisseur)
+7. [Exigences d'intégration](#exigences-dintégration)
+8. [Points de terminaison API](#points-de-terminaison-api)
+9. [Configuration](#configuration)
+10. [Créer un nouveau fournisseur dans PicoClaw](#créer-un-nouveau-fournisseur-dans-picoclaw)
+
+---
+
+## Flux d'authentification
+
+### 1. OAuth 2.0 avec PKCE
+
+Antigravity utilise **OAuth 2.0 avec PKCE (Proof Key for Code Exchange)** pour une authentification sécurisée :
+
+```
+┌─────────────┐ ┌─────────────────┐
+│ Client │ ───(1) Generate PKCE Pair────────> │ │
+│ │ ───(2) Open Auth URL─────────────> │ Google OAuth │
+│ │ │ Server │
+│ │ <──(3) Redirect with Code───────── │ │
+│ │ └─────────────────┘
+│ │ ───(4) Exchange Code for Tokens──> │ Token URL │
+│ │ │ │
+│ │ <──(5) Access + Refresh Tokens──── │ │
+└─────────────┘ └─────────────────┘
+```
+
+### 2. Étapes détaillées
+
+#### Étape 1 : Générer les paramètres PKCE
+```typescript
+function generatePkce(): { verifier: string; challenge: string } {
+ const verifier = randomBytes(32).toString("hex");
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
+ return { verifier, challenge };
+}
+```
+
+#### Étape 2 : Construire l'URL d'autorisation
+```typescript
+const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
+const REDIRECT_URI = "http://localhost:51121/oauth-callback";
+
+function buildAuthUrl(params: { challenge: string; state: string }): string {
+ const url = new URL(AUTH_URL);
+ url.searchParams.set("client_id", CLIENT_ID);
+ url.searchParams.set("response_type", "code");
+ url.searchParams.set("redirect_uri", REDIRECT_URI);
+ url.searchParams.set("scope", SCOPES.join(" "));
+ url.searchParams.set("code_challenge", params.challenge);
+ url.searchParams.set("code_challenge_method", "S256");
+ url.searchParams.set("state", params.state);
+ url.searchParams.set("access_type", "offline");
+ url.searchParams.set("prompt", "consent");
+ return url.toString();
+}
+```
+
+**Portées requises :**
+```typescript
+const SCOPES = [
+ "https://www.googleapis.com/auth/cloud-platform",
+ "https://www.googleapis.com/auth/userinfo.email",
+ "https://www.googleapis.com/auth/userinfo.profile",
+ "https://www.googleapis.com/auth/cclog",
+ "https://www.googleapis.com/auth/experimentsandconfigs",
+];
+```
+
+#### Étape 3 : Gérer le callback OAuth
+
+**Mode automatique (développement local) :**
+- Démarrer un serveur HTTP local sur le port 51121
+- Attendre la redirection de Google
+- Extraire le code d'autorisation des paramètres de requête
+
+**Mode manuel (distant/sans interface graphique) :**
+- Afficher l'URL d'autorisation à l'utilisateur
+- L'utilisateur complète l'authentification dans son navigateur
+- L'utilisateur colle l'URL de redirection complète dans le terminal
+- Analyser le code depuis l'URL collée
+
+#### Étape 4 : Échanger le code contre des jetons
+```typescript
+const TOKEN_URL = "https://oauth2.googleapis.com/token";
+
+async function exchangeCode(params: {
+ code: string;
+ verifier: string;
+}): Promise<{ access: string; refresh: string; expires: number }> {
+ const response = await fetch(TOKEN_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ client_id: CLIENT_ID,
+ client_secret: CLIENT_SECRET,
+ code: params.code,
+ grant_type: "authorization_code",
+ redirect_uri: REDIRECT_URI,
+ code_verifier: params.verifier,
+ }),
+ });
+
+ const data = await response.json();
+
+ return {
+ access: data.access_token,
+ refresh: data.refresh_token,
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer
+ };
+}
+```
+
+#### Étape 5 : Récupérer les données utilisateur supplémentaires
+
+**E-mail de l'utilisateur :**
+```typescript
+async function fetchUserEmail(accessToken: string): Promise {
+ const response = await fetch(
+ "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
+ { headers: { Authorization: `Bearer ${accessToken}` } }
+ );
+ const data = await response.json();
+ return data.email;
+}
+```
+
+**ID du projet (requis pour les appels API) :**
+```typescript
+async function fetchProjectId(accessToken: string): Promise {
+ const headers = {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "google-api-nodejs-client/9.15.1",
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+ "Client-Metadata": JSON.stringify({
+ ideType: "IDE_UNSPECIFIED",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ }),
+ };
+
+ const response = await fetch(
+ "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ metadata: {
+ ideType: "IDE_UNSPECIFIED",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ },
+ }),
+ }
+ );
+
+ const data = await response.json();
+ return data.cloudaicompanionProject || "rising-fact-p41fc"; // Valeur par défaut
+}
+```
+
+---
+
+## Détails de l'implémentation OAuth
+
+### Identifiants client
+
+**Important :** Ceux-ci sont encodés en base64 dans le code source pour la synchronisation avec pi-ai :
+
+```typescript
+const decode = (s: string) => Buffer.from(s, "base64").toString();
+
+const CLIENT_ID = decode(
+ "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ=="
+);
+const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
+```
+
+### Modes de flux OAuth
+
+1. **Flux automatique** (machines locales avec navigateur) :
+ - Ouvre le navigateur automatiquement
+ - Le serveur de callback local capture la redirection
+ - Aucune interaction utilisateur requise après l'authentification initiale
+
+2. **Flux manuel** (distant/sans interface/WSL2) :
+ - URL affichée pour copier-coller manuellement
+ - L'utilisateur complète l'authentification dans un navigateur externe
+ - L'utilisateur colle l'URL de redirection complète
+
+```typescript
+function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
+ return isRemote || isWSL2Sync();
+}
+```
+
+---
+
+## Gestion des jetons
+
+### Structure du profil d'authentification
+
+```typescript
+type OAuthCredential = {
+ type: "oauth";
+ provider: "google-antigravity";
+ access: string; // Jeton d'accès
+ refresh: string; // Jeton de rafraîchissement
+ expires: number; // Horodatage d'expiration (ms depuis epoch)
+ email?: string; // E-mail de l'utilisateur
+ projectId?: string; // ID du projet Google Cloud
+};
+```
+
+### Rafraîchissement des jetons
+
+Les identifiants incluent un jeton de rafraîchissement qui peut être utilisé pour obtenir de nouveaux jetons d'accès lorsque le jeton actuel expire. L'expiration est définie avec un tampon de 5 minutes pour éviter les conditions de concurrence.
+
+---
+
+## Récupération de la liste des modèles
+
+### Récupérer les modèles disponibles
+
+```typescript
+const BASE_URL = "https://cloudcode-pa.googleapis.com";
+
+async function fetchAvailableModels(
+ accessToken: string,
+ projectId: string
+): Promise {
+ const headers = {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "antigravity",
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+ };
+
+ const response = await fetch(
+ `${BASE_URL}/v1internal:fetchAvailableModels`,
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ project: projectId }),
+ }
+ );
+
+ const data = await response.json();
+
+ // Retourne les modèles avec les informations de quota
+ return Object.entries(data.models).map(([modelId, modelInfo]) => ({
+ id: modelId,
+ displayName: modelInfo.displayName,
+ quotaInfo: {
+ remainingFraction: modelInfo.quotaInfo?.remainingFraction,
+ resetTime: modelInfo.quotaInfo?.resetTime,
+ isExhausted: modelInfo.quotaInfo?.isExhausted,
+ },
+ }));
+}
+```
+
+### Format de réponse
+
+```typescript
+type FetchAvailableModelsResponse = {
+ models?: Record;
+};
+```
+
+---
+
+## Suivi de l'utilisation
+
+### Récupérer les données d'utilisation
+
+```typescript
+export async function fetchAntigravityUsage(
+ token: string,
+ timeoutMs: number
+): Promise {
+ // 1. Récupérer les crédits et les informations du plan
+ const loadCodeAssistRes = await fetch(
+ `${BASE_URL}/v1internal:loadCodeAssist`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ metadata: {
+ ideType: "ANTIGRAVITY",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ },
+ }),
+ }
+ );
+
+ // Extraire les informations de crédits
+ const { availablePromptCredits, planInfo, currentTier } = data;
+
+ // 2. Récupérer les quotas des modèles
+ const modelsRes = await fetch(
+ `${BASE_URL}/v1internal:fetchAvailableModels`,
+ {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}` },
+ body: JSON.stringify({ project: projectId }),
+ }
+ );
+
+ // Construire les fenêtres d'utilisation
+ return {
+ provider: "google-antigravity",
+ displayName: "Google Antigravity",
+ windows: [
+ { label: "Credits", usedPercent: calculateUsedPercent(available, monthly) },
+ // Quotas individuels des modèles...
+ ],
+ plan: currentTier?.name || planType,
+ };
+}
+```
+
+### Structure de la réponse d'utilisation
+
+```typescript
+type ProviderUsageSnapshot = {
+ provider: "google-antigravity";
+ displayName: string;
+ windows: UsageWindow[];
+ plan?: string;
+ error?: string;
+};
+
+type UsageWindow = {
+ label: string; // "Credits" ou ID du modèle
+ usedPercent: number; // 0-100
+ resetAt?: number; // Horodatage de réinitialisation du quota
+};
+```
+
+---
+
+## Structure du plugin fournisseur
+
+### Définition du plugin
+
+```typescript
+const antigravityPlugin = {
+ id: "google-antigravity-auth",
+ name: "Google Antigravity Auth",
+ description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
+ configSchema: emptyPluginConfigSchema(),
+
+ register(api: PicoClawPluginApi) {
+ api.registerProvider({
+ id: "google-antigravity",
+ label: "Google Antigravity",
+ docsPath: "/providers/models",
+ aliases: ["antigravity"],
+
+ auth: [
+ {
+ id: "oauth",
+ label: "Google OAuth",
+ hint: "PKCE + localhost callback",
+ kind: "oauth",
+ run: async (ctx: ProviderAuthContext) => {
+ // Implémentation OAuth ici
+ },
+ },
+ ],
+ });
+ },
+};
+```
+
+### ProviderAuthContext
+
+```typescript
+type ProviderAuthContext = {
+ config: PicoClawConfig;
+ agentDir?: string;
+ workspaceDir?: string;
+ prompter: WizardPrompter; // Invites/notifications UI
+ runtime: RuntimeEnv; // Journalisation, etc.
+ isRemote: boolean; // Exécution à distance ou non
+ openUrl: (url: string) => Promise; // Ouverture du navigateur
+ oauth: {
+ createVpsAwareHandlers: Function;
+ };
+};
+```
+
+### ProviderAuthResult
+
+```typescript
+type ProviderAuthResult = {
+ profiles: Array<{
+ profileId: string;
+ credential: AuthProfileCredential;
+ }>;
+ configPatch?: Partial;
+ defaultModel?: string;
+ notes?: string[];
+};
+```
+
+---
+
+## Exigences d'intégration
+
+### 1. Environnement/dépendances requis
+
+- Go ≥ 1.25
+- Base de code PicoClaw (`pkg/providers/` et `pkg/auth/`)
+- Packages de la bibliothèque standard `crypto` et `net/http`
+
+### 2. En-têtes requis pour les appels API
+
+```typescript
+const REQUIRED_HEADERS = {
+ "Authorization": `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "antigravity", // ou "google-api-nodejs-client/9.15.1"
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+};
+
+// Pour les appels loadCodeAssist, inclure également :
+const CLIENT_METADATA = {
+ ideType: "ANTIGRAVITY", // ou "IDE_UNSPECIFIED"
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+};
+```
+
+### 3. Assainissement des schémas de modèles
+
+Antigravity utilise des modèles compatibles Gemini, les schémas d'outils doivent donc être assainis :
+
+```typescript
+const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
+ "patternProperties",
+ "additionalProperties",
+ "$schema",
+ "$id",
+ "$ref",
+ "$defs",
+ "definitions",
+ "examples",
+ "minLength",
+ "maxLength",
+ "minimum",
+ "maximum",
+ "multipleOf",
+ "pattern",
+ "format",
+ "minItems",
+ "maxItems",
+ "uniqueItems",
+ "minProperties",
+ "maxProperties",
+]);
+
+// Nettoyer le schéma avant l'envoi
+function cleanToolSchemaForGemini(schema: Record): unknown {
+ // Supprimer les mots-clés non supportés
+ // S'assurer que le niveau supérieur a type: "object"
+ // Aplatir les unions anyOf/oneOf
+}
+```
+
+### 4. Gestion des blocs de réflexion (modèles Claude)
+
+Pour les modèles Claude via Antigravity, les blocs de réflexion nécessitent un traitement spécial :
+
+```typescript
+const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/;
+
+export function sanitizeAntigravityThinkingBlocks(
+ messages: AgentMessage[]
+): AgentMessage[] {
+ // Valider les signatures de réflexion
+ // Normaliser les champs de signature
+ // Rejeter les blocs de réflexion non signés
+}
+```
+
+---
+
+## Points de terminaison API
+
+### Points de terminaison d'authentification
+
+| Point de terminaison | Méthode | Objectif |
+|---------------------|---------|----------|
+| `https://accounts.google.com/o/oauth2/v2/auth` | GET | Autorisation OAuth |
+| `https://oauth2.googleapis.com/token` | POST | Échange de jetons |
+| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | Informations utilisateur (e-mail) |
+
+### Points de terminaison Cloud Code Assist
+
+| Point de terminaison | Méthode | Objectif |
+|---------------------|---------|----------|
+| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | Charger les infos du projet, crédits, plan |
+| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | Lister les modèles disponibles avec quotas |
+| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | Point de terminaison de streaming de chat |
+
+**Format de requête API (chat) :**
+Le point de terminaison `v1internal:streamGenerateContent` attend une enveloppe encapsulant la requête Gemini standard :
+
+```json
+{
+ "project": "your-project-id",
+ "model": "model-id",
+ "request": {
+ "contents": [...],
+ "systemInstruction": {...},
+ "generationConfig": {...},
+ "tools": [...]
+ },
+ "requestType": "agent",
+ "userAgent": "antigravity",
+ "requestId": "agent-timestamp-random"
+}
+```
+
+**Format de réponse API (SSE) :**
+Chaque message SSE (`data: {...}`) est encapsulé dans un champ `response` :
+
+```json
+{
+ "response": {
+ "candidates": [...],
+ "usageMetadata": {...},
+ "modelVersion": "...",
+ "responseId": "..."
+ },
+ "traceId": "...",
+ "metadata": {}
+}
+```
+
+---
+
+## Configuration
+
+### Configuration config.json
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gemini-flash",
+ "model": "antigravity/gemini-3-flash",
+ "auth_method": "oauth"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model_name": "gemini-flash"
+ }
+ }
+}
+```
+
+### Stockage du profil d'authentification
+
+Les profils d'authentification sont stockés dans `~/.picoclaw/auth.json` :
+
+```json
+{
+ "credentials": {
+ "google-antigravity": {
+ "access_token": "ya29...",
+ "refresh_token": "1//...",
+ "expires_at": "2026-01-01T00:00:00Z",
+ "provider": "google-antigravity",
+ "auth_method": "oauth",
+ "email": "user@example.com",
+ "project_id": "my-project-id"
+ }
+ }
+}
+```
+
+---
+
+## Créer un nouveau fournisseur dans PicoClaw
+
+Les fournisseurs PicoClaw sont implémentés en tant que packages Go sous `pkg/providers/`. Pour ajouter un nouveau fournisseur :
+
+### Implémentation étape par étape
+
+#### 1. Créer le fichier du fournisseur
+
+Créez un nouveau fichier Go dans `pkg/providers/` :
+
+```
+pkg/providers/
+└── your_provider.go
+```
+
+#### 2. Implémenter l'interface Provider
+
+Votre fournisseur doit implémenter l'interface `Provider` définie dans `pkg/providers/types.go` :
+
+```go
+package providers
+
+type YourProvider struct {
+ apiKey string
+ apiBase string
+}
+
+func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider {
+ if apiBase == "" {
+ apiBase = "https://api.your-provider.com/v1"
+ }
+ return &YourProvider{apiKey: apiKey, apiBase: apiBase}
+}
+
+func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error {
+ // Implémenter la complétion de chat avec streaming
+}
+```
+
+#### 3. Enregistrer dans la factory
+
+Ajoutez votre fournisseur au switch de protocole dans `pkg/providers/factory.go` :
+
+```go
+case "your-provider":
+ return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil
+```
+
+#### 4. Ajouter la configuration par défaut (optionnel)
+
+Ajoutez une entrée par défaut dans `pkg/config/defaults.go` :
+
+```go
+{
+ ModelName: "your-model",
+ Model: "your-provider/model-name",
+ APIKey: "",
+},
+```
+
+#### 5. Ajouter le support d'authentification (optionnel)
+
+Si votre fournisseur nécessite OAuth ou une authentification spéciale, ajoutez un cas dans `cmd/picoclaw/internal/auth/helpers.go` :
+
+```go
+case "your-provider":
+ authLoginYourProvider()
+```
+
+#### 6. Configurer via `config.json`
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "your-model",
+ "model": "your-provider/model-name",
+ "api_key": "your-api-key",
+ "api_base": "https://api.your-provider.com/v1"
+ }
+ ]
+}
+```
+
+---
+
+## Tester votre implémentation
+
+### Commandes CLI
+
+```bash
+# S'authentifier avec un fournisseur
+picoclaw auth login --provider your-provider
+
+# Lister les modèles (pour Antigravity)
+picoclaw auth models
+
+# Démarrer la passerelle
+picoclaw gateway
+
+# Exécuter un agent avec un modèle spécifique
+picoclaw agent -m "Hello" --model your-model
+```
+
+### Variables d'environnement pour les tests
+
+```bash
+# Remplacer le modèle par défaut
+export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model
+
+# Remplacer les paramètres du fournisseur
+export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]'
+```
+
+---
+
+## Références
+
+- **Fichiers source :**
+ - `pkg/providers/antigravity_provider.go` - Implémentation du fournisseur Antigravity
+ - `pkg/auth/oauth.go` - Implémentation du flux OAuth
+ - `pkg/auth/store.go` - Stockage des identifiants d'authentification (`~/.picoclaw/auth.json`)
+ - `pkg/providers/factory.go` - Factory des fournisseurs et routage de protocole
+ - `pkg/providers/types.go` - Définitions de l'interface fournisseur
+ - `cmd/picoclaw/internal/auth/helpers.go` - Commandes CLI d'authentification
+
+- **Documentation :**
+ - `docs/ANTIGRAVITY_USAGE.md` - Guide d'utilisation d'Antigravity
+ - `docs/migration/model-list-migration.md` - Guide de migration
+
+---
+
+## Notes
+
+1. **Projet Google Cloud :** Antigravity nécessite que Gemini for Google Cloud soit activé sur votre projet Google Cloud
+2. **Quotas :** Utilise les quotas du projet Google Cloud (pas de facturation séparée)
+3. **Accès aux modèles :** Les modèles disponibles dépendent de la configuration de votre projet Google Cloud
+4. **Blocs de réflexion :** Les modèles Claude via Antigravity nécessitent un traitement spécial des blocs de réflexion avec signatures
+5. **Assainissement des schémas :** Les schémas d'outils doivent être assainis pour supprimer les mots-clés JSON Schema non supportés
+
+---
+
+---
+
+## Gestion des erreurs courantes
+
+### 1. Limitation de débit (HTTP 429)
+
+Antigravity retourne une erreur 429 lorsque les quotas du projet/modèle sont épuisés. La réponse d'erreur contient souvent un `quotaResetDelay` dans le champ `details`.
+
+**Exemple d'erreur 429 :**
+```json
+{
+ "error": {
+ "code": 429,
+ "message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.",
+ "status": "RESOURCE_EXHAUSTED",
+ "details": [
+ {
+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
+ "metadata": {
+ "quotaResetDelay": "4h30m28.060903746s"
+ }
+ }
+ ]
+ }
+}
+```
+
+### 2. Réponses vides (modèles restreints)
+
+Certains modèles peuvent apparaître dans la liste des modèles disponibles mais retourner une réponse vide (200 OK mais flux SSE vide). Cela se produit généralement pour les modèles en préversion ou restreints que le projet actuel n'a pas la permission d'utiliser.
+
+**Traitement :** Traiter les réponses vides comme des erreurs informant l'utilisateur que le modèle pourrait être restreint ou invalide pour son projet.
+
+---
+
+## Dépannage
+
+### "Token expired" (jeton expiré)
+- Rafraîchir les jetons OAuth : `picoclaw auth login --provider antigravity`
+
+### "Gemini for Google Cloud is not enabled" (Gemini for Google Cloud n'est pas activé)
+- Activer l'API dans votre Google Cloud Console
+
+### "Project not found" (projet non trouvé)
+- Vérifier que votre projet Google Cloud a les API nécessaires activées
+- Vérifier que l'ID du projet est correctement récupéré lors de l'authentification
+
+### Les modèles n'apparaissent pas dans la liste
+- Vérifier que l'authentification OAuth s'est terminée avec succès
+- Vérifier le stockage du profil d'authentification : `~/.picoclaw/auth.json`
+- Relancer `picoclaw auth login --provider antigravity`
diff --git a/docs/fr/ANTIGRAVITY_USAGE.md b/docs/fr/ANTIGRAVITY_USAGE.md
new file mode 100644
index 000000000..d6d0a2bd4
--- /dev/null
+++ b/docs/fr/ANTIGRAVITY_USAGE.md
@@ -0,0 +1,72 @@
+> Retour au [README](../../README.fr.md)
+
+# Utiliser le fournisseur Antigravity dans PicoClaw
+
+Ce guide explique comment configurer et utiliser le fournisseur **Antigravity** (Google Cloud Code Assist) dans PicoClaw.
+
+## Prérequis
+
+1. Un compte Google.
+2. Google Cloud Code Assist activé (généralement disponible via l'intégration « Gemini for Google Cloud »).
+
+## 1. Authentification
+
+Pour vous authentifier avec Antigravity, exécutez la commande suivante :
+
+```bash
+picoclaw auth login --provider antigravity
+```
+
+### Authentification manuelle (Headless/VPS)
+Si vous exécutez PicoClaw sur un serveur (Coolify/Docker) et ne pouvez pas accéder à `localhost`, suivez ces étapes :
+1. Exécutez la commande ci-dessus.
+2. Copiez l'URL fournie et ouvrez-la dans votre navigateur local.
+3. Complétez la connexion.
+4. Votre navigateur sera redirigé vers une URL `localhost:51121` (qui ne se chargera pas).
+5. **Copiez cette URL finale** depuis la barre d'adresse de votre navigateur.
+6. **Collez-la dans le terminal** où PicoClaw attend.
+
+PicoClaw extraira automatiquement le code d'autorisation et terminera le processus.
+
+## 2. Gestion des modèles
+
+### Lister les modèles disponibles
+Pour voir quels modèles sont accessibles à votre projet et vérifier leurs quotas :
+
+```bash
+picoclaw auth models
+```
+
+### Changer de modèle
+Vous pouvez modifier le modèle par défaut dans `~/.picoclaw/config.json` ou le remplacer via le CLI :
+
+```bash
+# Remplacer pour une seule commande
+picoclaw agent -m "Hello" --model claude-opus-4-6-thinking
+```
+
+## 3. Utilisation en production (Coolify/Docker)
+
+Si vous déployez via Coolify ou Docker, suivez ces étapes pour tester :
+
+1. **Variables d'environnement** :
+ * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash`
+2. **Persistance de l'authentification** :
+ Si vous vous êtes connecté localement, vous pouvez copier vos identifiants vers le serveur :
+ ```bash
+ scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/
+ ```
+ *Alternativement*, exécutez la commande `auth login` une fois sur le serveur si vous avez un accès terminal.
+
+## 4. Dépannage
+
+* **Réponse vide** : Si un modèle renvoie une réponse vide, il peut être restreint pour votre projet. Essayez `gemini-3-flash` ou `claude-opus-4-6-thinking`.
+* **429 Limite de débit** : Antigravity a des quotas stricts. PicoClaw affichera le « temps de réinitialisation » dans le message d'erreur si vous atteignez une limite.
+* **404 Non trouvé** : Assurez-vous d'utiliser un ID de modèle provenant de la liste `picoclaw auth models`. Utilisez l'ID court (par ex. `gemini-3-flash`) et non le chemin complet.
+
+## 5. Résumé des modèles fonctionnels
+
+D'après les tests, les modèles suivants sont les plus fiables :
+* `gemini-3-flash` (Rapide, haute disponibilité)
+* `gemini-2.5-flash-lite` (Léger)
+* `claude-opus-4-6-thinking` (Puissant, inclut le raisonnement)
diff --git a/docs/fr/chat-apps.md b/docs/fr/chat-apps.md
index 39026e0df..67422e0ec 100644
--- a/docs/fr/chat-apps.md
+++ b/docs/fr/chat-apps.md
@@ -8,22 +8,22 @@ Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, Din
> **Note** : Tous les canaux basés sur les webhooks (LINE, WeCom, etc.) sont servis sur un seul serveur HTTP Gateway partagé (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Il n'y a pas de ports par canal à configurer. Note : Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP webhook partagé.
-| Canal | Configuration |
-| ------------ | -------------------------------------- |
-| **Telegram** | Facile (juste un token) |
-| **Discord** | Facile (bot token + intents) |
-| **WhatsApp** | Facile (natif : scan QR ; ou bridge URL) |
-| **Matrix** | Moyen (homeserver + bot access token) |
-| **QQ** | Facile (AppID + AppSecret) |
-| **DingTalk** | Moyen (identifiants de l'application) |
-| **LINE** | Moyen (identifiants + webhook URL) |
-| **WeCom AI Bot** | Moyen (Token + clé AES) |
-| **Feishu** | Moyen (App ID + Secret, mode WebSocket) |
-| **Slack** | Moyen (Bot token + App token) |
-| **IRC** | Moyen (serveur + configuration TLS) |
-| **OneBot** | Moyen (QQ via protocole OneBot) |
-| **MaixCam** | Facile (intégration matérielle Sipeed) |
-| **Pico** | Native PicoClaw protocol |
+| Canal | Difficulté | Description | Documentation |
+| -------------------- | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
+| **Telegram** | ⭐ Facile | Recommandé, transcription vocale, long polling (pas d'IP publique requise) | [Documentation](../channels/telegram/README.fr.md) |
+| **Discord** | ⭐ Facile | Socket Mode, groupes/DM, écosystème bot riche | [Documentation](../channels/discord/README.fr.md) |
+| **WhatsApp** | ⭐ Facile | Natif (scan QR) ou Bridge URL | [Documentation](#whatsapp) |
+| **Slack** | ⭐ Facile | **Socket Mode** (pas d'IP publique requise), entreprise | [Documentation](../channels/slack/README.fr.md) |
+| **Matrix** | ⭐⭐ Moyen | Protocole fédéré, auto-hébergement possible | [Documentation](../channels/matrix/README.fr.md) |
+| **QQ** | ⭐⭐ Moyen | API bot officielle, communauté chinoise | [Documentation](../channels/qq/README.fr.md) |
+| **DingTalk** | ⭐⭐ Moyen | Mode Stream (pas d'IP publique requise), entreprise | [Documentation](../channels/dingtalk/README.fr.md) |
+| **LINE** | ⭐⭐⭐ Avancé | HTTPS Webhook requis | [Documentation](../channels/line/README.fr.md) |
+| **WeCom (企业微信)** | ⭐⭐⭐ Avancé | Bot groupe (Webhook), app personnalisée (API), AI Bot | [Bot](../channels/wecom/wecom_bot/README.fr.md) / [App](../channels/wecom/wecom_app/README.fr.md) / [AI Bot](../channels/wecom/wecom_aibot/README.fr.md) |
+| **Feishu (飞书)** | ⭐⭐⭐ Avancé | Collaboration entreprise, fonctionnalités riches | [Documentation](../channels/feishu/README.fr.md) |
+| **IRC** | ⭐⭐ Moyen | Serveur + configuration TLS | - |
+| **OneBot** | ⭐⭐ Moyen | Compatible NapCat/Go-CQHTTP, écosystème communautaire | [Documentation](../channels/onebot/README.fr.md) |
+| **MaixCam** | ⭐ Facile | Canal d'intégration matérielle pour caméras AI Sipeed | [Documentation](../channels/maixcam/README.fr.md) |
+| **Pico** | ⭐ Facile | Canal protocole natif PicoClaw | |
Telegram (Recommandé)
@@ -168,12 +168,13 @@ Si `session_store_path` est vide, la session est stockée dans `/what
QQ
-**1. Créer un bot**
+**Configuration rapide (recommandée)**
-- Allez sur [QQ Open Platform](https://q.qq.com/#)
-- Créez une application → Obtenez **AppID** et **AppSecret**
+QQ Open Platform propose une page de configuration en un clic pour les bots compatibles OpenClaw :
-**2. Configurer**
+1. Ouvrez [QQ Bot Quick Start](https://q.qq.com/qqbot/openclaw/index.html) et scannez le QR code pour vous connecter
+2. Un bot est créé automatiquement — copiez l'**App ID** et l'**App Secret**
+3. Configurez PicoClaw :
```json
{
@@ -188,13 +189,20 @@ Si `session_store_path` est vide, la session est stockée dans `/what
}
```
-> Définissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des numéros QQ pour restreindre l'accès.
+4. Lancez `picoclaw gateway` et ouvrez QQ pour discuter avec votre bot
-**3. Lancer**
+> L'App Secret n'est affiché qu'une seule fois. Enregistrez-le immédiatement — le consulter à nouveau forcera une réinitialisation.
+>
+> Les bots créés via la page de configuration rapide sont initialement réservés au créateur et ne prennent pas en charge les discussions de groupe. Pour activer l'accès en groupe, configurez le mode sandbox sur la [QQ Open Platform](https://q.qq.com/).
-```bash
-picoclaw gateway
-```
+**Configuration manuelle**
+
+Si vous préférez créer le bot manuellement :
+
+* Connectez-vous sur [QQ Open Platform](https://q.qq.com/) pour vous inscrire en tant que développeur
+* Créez un bot QQ — personnalisez son avatar et son nom
+* Copiez l'**App ID** et l'**App Secret** depuis les paramètres du bot
+* Configurez comme indiqué ci-dessus et lancez `picoclaw gateway`
@@ -261,7 +269,7 @@ picoclaw gateway
picoclaw gateway
```
-Pour toutes les options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), voir le [Guide de Configuration du Canal Matrix](docs/channels/matrix/README.md).
+Pour toutes les options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), voir le [Guide de Configuration du Canal Matrix](../channels/matrix/README.md).
@@ -322,7 +330,7 @@ PicoClaw prend en charge trois types d'intégration WeCom :
**Option 2 : WeCom App (Application personnalisée)** - Plus de fonctionnalités, messagerie proactive, chat privé uniquement
**Option 3 : WeCom AI Bot (Bot IA)** - Bot IA officiel, réponses en streaming, prend en charge les discussions de groupe et privées
-Voir le [Guide de Configuration WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) pour les instructions détaillées.
+Voir le [Guide de Configuration WeCom AI Bot](../channels/wecom/wecom_aibot/README.fr.md) pour les instructions détaillées.
**Configuration rapide - WeCom Bot :**
@@ -396,7 +404,7 @@ picoclaw gateway
**1. Créer un AI Bot**
* Allez dans la console d'administration WeCom → Gestion des applications → AI Bot
-* Dans les paramètres du AI Bot, configurez l'URL de callback : `http://your-server:18791/webhook/wecom-aibot`
+* Dans les paramètres du AI Bot, configurez l'URL de callback : `http://your-server:18790/webhook/wecom-aibot`
* Copiez **Token** et cliquez sur "Générer aléatoirement" pour **EncodingAESKey**
**2. Configurer**
@@ -430,10 +438,14 @@ picoclaw gateway
Feishu (飞书)
+PicoClaw se connecte à Feishu via le mode WebSocket/SDK — aucune URL webhook publique ni serveur de callback nécessaire.
+
**1. Créer une application**
-* Allez sur [Feishu Open Platform](https://open.feishu.cn/)
-* Créez une application → Obtenez **App ID** et **App Secret**
+* Allez sur [Feishu Open Platform](https://open.feishu.cn/) et créez une application
+* Dans les paramètres de l'application, activez la capacité **Bot**
+* Créez une version et publiez l'application (l'application doit être publiée pour prendre effet)
+* Copiez l'**App ID** (commence par `cli_`) et l'**App Secret**
**2. Configurer**
@@ -443,23 +455,25 @@ picoclaw gateway
"feishu": {
"enabled": true,
"app_id": "cli_xxx",
- "app_secret": "xxx",
- "encrypt_key": "",
- "verification_token": "",
+ "app_secret": "YOUR_APP_SECRET",
"allow_from": []
}
}
}
```
-> Feishu utilise le mode WebSocket/SDK et ne nécessite pas de serveur webhook.
+Optionnel : `encrypt_key` et `verification_token` pour le chiffrement des événements (recommandé en production).
-**3. Lancer**
+**3. Lancer et discuter**
```bash
picoclaw gateway
```
+Ouvrez Feishu, recherchez le nom de votre bot et commencez à discuter. Vous pouvez aussi ajouter le bot à un groupe — utilisez `group_trigger.mention_only: true` pour ne répondre que lorsqu'il est @mentionné.
+
+Pour toutes les options, voir le [Guide de Configuration du Canal Feishu](../channels/feishu/README.fr.md).
+
@@ -467,9 +481,10 @@ picoclaw gateway
**1. Créer une application Slack**
-* Allez sur [Slack API](https://api.slack.com/apps)
-* Créez une nouvelle application
-* Obtenez le **Bot Token** et l'**App Token**
+* Allez sur [Slack API](https://api.slack.com/apps) et créez une nouvelle application
+* Sous **OAuth & Permissions**, ajoutez les scopes bot : `chat:write`, `app_mentions:read`, `im:history`, `im:read`, `im:write`
+* Installez l'application dans votre workspace
+* Copiez le **Bot Token** (`xoxb-...`) et l'**App-Level Token** (`xapp-...`, activez Socket Mode pour l'obtenir)
**2. Configurer**
@@ -478,8 +493,8 @@ picoclaw gateway
"channels": {
"slack": {
"enabled": true,
- "bot_token": "xoxb-your-bot-token",
- "app_token": "xapp-your-app-token",
+ "bot_token": "xoxb-YOUR-BOT-TOKEN",
+ "app_token": "xapp-YOUR-APP-TOKEN",
"allow_from": []
}
}
@@ -497,42 +512,44 @@ picoclaw gateway
IRC
-**1. Configurer le serveur IRC**
-
-* Préparez les informations de votre serveur IRC (adresse, port, canal)
-
-**2. Configurer**
+**1. Configurer**
```json
{
"channels": {
"irc": {
"enabled": true,
- "server": "irc.example.com:6697",
+ "server": "irc.libera.chat:6697",
+ "tls": true,
"nick": "picoclaw-bot",
- "channel": "#your-channel",
- "use_tls": true,
+ "channels": ["#your-channel"],
+ "password": "",
"allow_from": []
}
}
}
```
-**3. Lancer**
+Optionnel : `nickserv_password` pour l'authentification NickServ, `sasl_user`/`sasl_password` pour l'authentification SASL.
+
+**2. Lancer**
```bash
picoclaw gateway
```
+Le bot se connectera au serveur IRC et rejoindra les canaux spécifiés.
+
-OneBot
+OneBot (QQ via protocole OneBot)
-**1. Configurer OneBot**
+OneBot est un protocole ouvert pour les bots QQ. PicoClaw se connecte à toute implémentation compatible OneBot v11 (par ex. [Lagrange](https://github.com/LagrangeDev/Lagrange.Core), [NapCat](https://github.com/NapNeko/NapCatQQ)) via WebSocket.
-* Installez une implémentation OneBot compatible (par ex. go-cqhttp, Lagrange)
-* Configurez la connexion WebSocket
+**1. Configurer une implémentation OneBot**
+
+Installez et exécutez un framework de bot QQ compatible OneBot v11. Activez son serveur WebSocket.
**2. Configurer**
@@ -541,14 +558,19 @@ picoclaw gateway
"channels": {
"onebot": {
"enabled": true,
- "ws_url": "ws://localhost:8080",
+ "ws_url": "ws://127.0.0.1:8080",
+ "access_token": "",
"allow_from": []
}
}
}
```
-> OneBot permet d'utiliser QQ via le protocole OneBot standard.
+| Champ | Description |
+|-------|-------------|
+| `ws_url` | URL WebSocket de l'implémentation OneBot |
+| `access_token` | Token d'accès pour l'authentification (si configuré dans OneBot) |
+| `reconnect_interval` | Intervalle de reconnexion en secondes (par défaut : 5) |
**3. Lancer**
diff --git a/docs/fr/configuration.md b/docs/fr/configuration.md
index ef02acf8a..d56da2cad 100644
--- a/docs/fr/configuration.md
+++ b/docs/fr/configuration.md
@@ -56,7 +56,7 @@ Par défaut, les compétences sont chargées depuis :
1. `~/.picoclaw/workspace/skills` (workspace)
2. `~/.picoclaw/skills` (global)
-3. `/skills` (builtin)
+3. `/skills` (intégré)
Pour les configurations avancées/de test, vous pouvez remplacer la racine des compétences builtin avec :
diff --git a/docs/fr/credential_encryption.md b/docs/fr/credential_encryption.md
new file mode 100644
index 000000000..eec765039
--- /dev/null
+++ b/docs/fr/credential_encryption.md
@@ -0,0 +1,159 @@
+> Retour au [README](../../README.fr.md)
+
+# Chiffrement des identifiants
+
+PicoClaw prend en charge le chiffrement des valeurs `api_key` dans les entrées de configuration `model_list`.
+Les clés chiffrées sont stockées sous forme de chaînes `enc://` et déchiffrées automatiquement au démarrage.
+
+---
+
+## Démarrage rapide
+
+**1. Définir votre phrase secrète**
+
+```bash
+export PICOCLAW_KEY_PASSPHRASE="your-passphrase"
+```
+
+**2. Chiffrer une clé API**
+
+Exécutez `picoclaw onboard` — il vous demande votre phrase secrète et génère la clé SSH,
+puis re-chiffre automatiquement toutes les entrées `api_key` en clair dans votre configuration
+lors du prochain appel à `SaveConfig`. La valeur `enc://` résultante ressemblera à :
+
+```
+enc://AAAA...base64...
+```
+
+**3. Coller la sortie dans votre configuration**
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-4o",
+ "model": "openai/gpt-4o",
+ "api_key": "enc://AAAA...base64...",
+ "api_base": "https://api.openai.com/v1"
+ }
+ ]
+}
+```
+
+---
+
+## Formats `api_key` pris en charge
+
+| Format | Exemple | Comportement |
+|--------|---------|--------------|
+| Texte clair | `sk-abc123` | Utilisé tel quel |
+| Référence fichier | `file://openai.key` | Contenu lu depuis le même répertoire que le fichier de configuration |
+| Chiffré | `enc://` | Déchiffré au démarrage avec `PICOCLAW_KEY_PASSPHRASE` |
+| Vide | `""` | Transmis tel quel (utilisé avec `auth_method: oauth`) |
+
+---
+
+## Conception cryptographique
+
+### Dérivation de clé
+
+Le chiffrement utilise **HKDF-SHA256** avec une clé privée SSH comme second facteur.
+
+```
+sshHash = SHA256(ssh_private_key_file_bytes)
+ikm = HMAC-SHA256(key=sshHash, message=passphrase)
+aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes)
+```
+
+### Chiffrement
+
+```
+AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key)
+```
+
+### Format de transmission
+
+```
+enc://
+```
+
+| Champ | Taille | Description |
+|-------|--------|-------------|
+| `salt` | 16 octets | Aléatoire par chiffrement ; fourni à HKDF |
+| `nonce` | 12 octets | Aléatoire par chiffrement ; IV AES-GCM |
+| `ciphertext` | variable | Texte chiffré AES-256-GCM + tag d'authentification de 16 octets |
+
+Le tag d'authentification GCM est automatiquement ajouté au texte chiffré. Toute altération provoque l'échec du déchiffrement avec une erreur plutôt que de retourner un texte clair corrompu.
+
+### Performance
+
+| Opération | Durée (ARM Cortex-A) |
+|-----------|----------------------|
+| Dérivation de clé (HKDF) | < 1 ms |
+| Déchiffrement AES-256-GCM | < 1 ms |
+| **Surcoût total au démarrage** | **< 2 ms par clé** |
+
+---
+
+## Sécurité à deux facteurs avec clé SSH
+
+Lorsqu'une clé privée SSH est fournie, casser le chiffrement nécessite **les deux** :
+
+1. La **phrase secrète** (`PICOCLAW_KEY_PASSPHRASE`)
+2. Le **fichier de clé privée SSH**
+
+Cela signifie qu'un fichier de configuration divulgué seul ne suffit pas pour récupérer la clé API, même si la phrase secrète est faible. La clé SSH apporte 256 bits d'entropie (Ed25519) indépendamment de la force de la phrase secrète.
+
+### Modèle de menace
+
+| Ce que l'attaquant possède | Peut-il déchiffrer ? |
+|---------------------------|---------------------|
+| Fichier de configuration uniquement | Non — nécessite la phrase secrète + la clé SSH |
+| Clé SSH uniquement | Non — nécessite la phrase secrète |
+| Phrase secrète uniquement | Non — nécessite la clé SSH |
+| Fichier de configuration + clé SSH + phrase secrète | Oui — compromission totale |
+
+---
+
+## Variables d'environnement
+
+| Variable | Requis | Description |
+|----------|--------|-------------|
+| `PICOCLAW_KEY_PASSPHRASE` | Oui (pour `enc://`) | Phrase secrète utilisée pour la dérivation de clé |
+| `PICOCLAW_SSH_KEY_PATH` | Non | Chemin vers la clé privée SSH. Si non défini, détection automatique depuis `~/.ssh/picoclaw_ed25519.key` |
+
+### Détection automatique de la clé SSH
+
+Si `PICOCLAW_SSH_KEY_PATH` n'est pas défini, PicoClaw recherche la clé dédiée :
+
+```
+~/.ssh/picoclaw_ed25519.key
+```
+
+Ce fichier dédié évite les conflits avec les clés SSH existantes de l'utilisateur.
+Exécutez `picoclaw onboard` pour le générer automatiquement.
+
+`os.UserHomeDir()` est utilisé pour la résolution multiplateforme du répertoire personnel (lit `USERPROFILE` sous Windows, `HOME` sous Unix/macOS).
+
+> **Remarque :** Un fichier de clé SSH est requis pour le chiffrement des identifiants. Si aucune clé n'est trouvée et que `PICOCLAW_SSH_KEY_PATH` n'est pas défini, le chiffrement/déchiffrement échouera. Exécutez `picoclaw onboard` pour générer la clé automatiquement.
+
+---
+
+## Migration
+
+Étant donné que les seuls éléments secrets sont `PICOCLAW_KEY_PASSPHRASE` et le fichier de clé privée SSH, la migration est simple :
+
+1. Copiez le fichier de configuration sur la nouvelle machine.
+2. Définissez `PICOCLAW_KEY_PASSPHRASE` avec la même valeur.
+3. Copiez le fichier de clé privée SSH au même chemin (ou définissez `PICOCLAW_SSH_KEY_PATH` vers son nouvel emplacement).
+
+Aucun re-chiffrement n'est nécessaire.
+
+---
+
+## Considérations de sécurité
+
+- **La phrase secrète et la clé SSH sont toutes deux requises.** La clé SSH agit comme un second facteur — sans elle, le chiffrement/déchiffrement échouera. Exécutez `picoclaw onboard` pour générer la clé si elle n'existe pas.
+- **La clé SSH est en lecture seule à l'exécution.** PicoClaw n'écrit ni ne modifie jamais le fichier de clé SSH.
+- **Les clés en texte clair restent prises en charge.** Les configurations existantes sans `enc://` ne sont pas affectées.
+- **Le format `enc://` est versionné** via le champ `info` de HKDF (`picoclaw-credential-v1`), permettant de futures mises à niveau d'algorithme sans casser les valeurs chiffrées existantes.
diff --git a/docs/fr/debug.md b/docs/fr/debug.md
new file mode 100644
index 000000000..5753ccf8c
--- /dev/null
+++ b/docs/fr/debug.md
@@ -0,0 +1,36 @@
+# Débogage de PicoClaw
+
+> Retour au [README](../../README.fr.md)
+
+PicoClaw effectue de multiples interactions complexes en arrière-plan pour chaque requête qu'il reçoit — du routage des messages et de l'évaluation de la complexité, à l'exécution des outils et à l'adaptation aux défaillances de modèle. Pouvoir voir exactement ce qui se passe est crucial, non seulement pour résoudre les problèmes potentiels, mais aussi pour véritablement comprendre le fonctionnement de l'agent.
+
+## Démarrer PicoClaw en mode débogage
+
+Pour obtenir des informations détaillées sur ce que fait l'agent (requêtes LLM, appels d'outils, routage des messages), vous pouvez démarrer la passerelle PicoClaw avec le drapeau de débogage :
+
+```bash
+picoclaw gateway --debug
+# or
+picoclaw gateway -d
+```
+
+Dans ce mode, le système formate les logs de manière détaillée et affiche des aperçus des prompts système et des résultats d'exécution des outils.
+
+## Désactiver la troncature des logs (logs complets)
+
+Par défaut, PicoClaw tronque les chaînes très longues (comme le *Prompt Système* ou les résultats JSON volumineux) dans les logs de débogage afin de garder la console lisible.
+
+Si vous avez besoin d'inspecter la sortie complète d'une commande ou le payload exact envoyé au modèle LLM, vous pouvez utiliser le drapeau `--no-truncate`.
+
+**Remarque :** Ce drapeau fonctionne *uniquement* en combinaison avec le mode `--debug`.
+
+```bash
+picoclaw gateway --debug --no-truncate
+
+```
+
+Lorsque ce drapeau est actif, la fonction de troncature globale est désactivée. Cela est extrêmement utile pour :
+
+* Vérifier la syntaxe exacte des messages envoyés au fournisseur.
+* Lire la sortie complète d'outils comme `exec`, `web_fetch` ou `read_file`.
+* Déboguer l'historique de session sauvegardé en mémoire.
diff --git a/docs/fr/docker.md b/docs/fr/docker.md
index f17ec355d..432edb1b2 100644
--- a/docs/fr/docker.md
+++ b/docs/fr/docker.md
@@ -12,6 +12,7 @@ git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
# 2. Premier lancement — génère automatiquement docker/data/config.json puis s'arrête
+# (se déclenche uniquement quand config.json et workspace/ sont tous deux absents)
docker compose -f docker/docker-compose.yml --profile gateway up
# Le conteneur affiche "First-run setup complete." et s'arrête.
diff --git a/docs/fr/hardware-compatibility.md b/docs/fr/hardware-compatibility.md
new file mode 100644
index 000000000..c1f397e80
--- /dev/null
+++ b/docs/fr/hardware-compatibility.md
@@ -0,0 +1,152 @@
+> Retour au [README](../../README.fr.md)
+
+# 🖥️ PicoClaw Liste de compatibilité matérielle
+
+PicoClaw fonctionne sur pratiquement n'importe quel appareil Linux. Cette page répertorie les puces, produits et cartes de développement vérifiés.
+
+**Votre matériel n'est pas listé ?** Soumettez une PR pour l'ajouter ! Les fabricants de matériel sont invités à contribuer et à co-promouvoir.
+
+---
+
+## 1. Support de puces vérifié
+
+### x86
+
+| Fabricant | Puce | Notes |
+|-----------|------|-------|
+| Intel | Any x86 CPU (i386+) | Tous les processeurs de bureau/serveur/portable |
+| AMD | Any x86 CPU | Tous les processeurs de bureau/serveur/portable |
+
+### ARM
+
+| Sous-arch | Puces typiques | Notes |
+|-----------|----------------|-------|
+| ARMv6 | [BCM2835](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2835) (Raspberry Pi 1/Zero) | Monocœur ARM1176JZF-S |
+| ARMv7 | [Allwinner V3s](https://linux-sunxi.org/V3s) | Monocœur Cortex-A7, utilisé dans LicheePi Zero |
+| ARM64 | [Allwinner H618](https://linux-sunxi.org/H618) | Quadricœur Cortex-A53, utilisé dans Orange Pi Zero 3 |
+| ARM64 | [BCM2711](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2711) (Raspberry Pi 4) | Quadricœur Cortex-A72 |
+| ARM64 | [BCM2712](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2712) (Raspberry Pi 5) | Quadricœur Cortex-A76 |
+| ARM64 | [AX630C](https://www.axera-tech.com/) (爱芯元智) | Bicœur Cortex-A53 + NPU, utilisé dans NanoKVM-Pro / MaixCAM2 |
+
+### RISC-V (riscv64)
+
+| Fabricant | Puce | Cœur | Notes |
+|-----------|------|------|-------|
+| [SOPHGO (算能)](https://www.sophgo.com/) | SG2002 | C906 @ 1GHz | 256MB DDR3 intégré, utilisé dans LicheeRV-Nano / NanoKVM / MaixCAM |
+| [Allwinner (全志)](https://www.allwinnertech.com/) | V861 | Dual C907 | 128MB DDR3L intégré, 1 TOPS NPU, caméra AI 4K SiP |
+| [Allwinner (全志)](https://www.allwinnertech.com/) | V881 | C907 | Série de caméras AI RISC-V |
+| [Arterytek (匠芯创)](https://www.arterytek.com/) | D213 | RISC-V | Utilisé dans HaaS506-LD1 RTU industriel |
+| [SpacemiT (进迭)](https://www.spacemit.com/) | K1 | 8x X60 @ 1.8GHz | Utilisé dans Milk-V Jupiter, BananaPi BPI-F3 |
+| [SpacemiT (进迭)](https://www.spacemit.com/) | K3 | 8x X100 @ 2.5GHz | Conforme RVA23, RVV 1024 bits, inférence AI FP8 |
+| [Zhihe (知合)](https://www.zhihe-tech.com/) | A210 | High-perf RISC-V | 8 cœurs, 16MB cache L3, classe bureau |
+| [Canaan (嘉楠)](https://www.canaan-creative.com/) | K230 | Dual C908 @ 1.6GHz | 6 TOPS KPU, utilisé dans CanMV-K230 |
+
+### MIPS
+
+| Fabricant | Puce | Notes |
+|-----------|------|-------|
+| MediaTek | [MT7620](https://www.mediatek.com/products/home-networking/mt7620) | MIPS24KEc @ 580MHz, utilisé dans de nombreux routeurs OpenWrt (ex. Xiaomi Router 3G) |
+
+### LoongArch (loong64)
+
+| Fabricant | Puce | Notes |
+|-----------|------|-------|
+| [Loongson (龙芯)](https://www.loongson.cn/) | 3A5000 | Quadricœur LA464 @ 2.5GHz, bureau/station de travail |
+| [Loongson (龙芯)](https://www.loongson.cn/) | 3A6000 | Quadricœur 4C/8T @ 2.5GHz, IPC comparable à Intel 10e génération |
+| [Loongson (龙芯)](https://www.loongson.cn/) | 2K1000LA | Bicœur @ 1GHz, applications industrielles/IoT |
+
+---
+
+## 2. Produits vérifiés (par date de sortie)
+
+Produits grand public, routeurs et appareils industriels testés avec PicoClaw.
+
+| Année | Produit | Arch | SoC | RAM | Catégorie |
+|-------|---------|------|-----|-----|-----------|
+| 2009 | Nokia N900 | ARM (A8) | OMAP3430 | 256MB | Smartphone |
+| 2012 | Samsung Galaxy Note 10.1 (N8000) | ARM (A9) | Exynos 4412 | 2GB | Tablette |
+| 2016 | Xiaomi Router 3G (小米路由器3G) | MIPS | MT7620 | 256MB | Routeur (OpenWrt) |
+| 2018 | Phicomm N1 (斐讯N1) | ARM64 (A53) | S905D | 2GB | Boîtier TV / Serveur domestique |
+| 2019 | Xiaomi AI Speaker (小爱音箱) | ARM64 (A53) | — | 256MB | Enceinte connectée |
+| 2024 | [NanoKVM](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html) | RISC-V | SG2002 | 256MB | IP-KVM |
+| 2025 | HaaS506-LD1 | RISC-V | D213 | 128MB | RTU industriel |
+| 2025 | [NanoKVM-Pro](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM_Pro/introduction.html) | ARM64 (A53) | AX630C | 1GB | IP-KVM Pro |
+| 2026 | [MaixCAM2](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | ARM64 (A53) | AX630C | 1/4GB | Caméra AI 4K |
+
+---
+
+## 3. Cartes de développement vérifiées (par date de sortie)
+
+| Année | Carte | Arch | SoC | RAM | Lien d'achat |
+|-------|-------|------|-----|-----|--------------|
+| 2012 | [Raspberry Pi 1 Model B](https://www.raspberrypi.com/products/) | ARMv6 | BCM2835 | 512MB | — |
+| 2015 | [Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) | ARMv7 (A7) | BCM2836 | 1GB | — |
+| 2015 | [Raspberry Pi Zero](https://www.raspberrypi.com/products/raspberry-pi-zero/) | ARMv6 | BCM2835 | 512MB | — |
+| 2016 | [Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) | ARM64 (A53) | BCM2837 | 1GB | — |
+| 2017 | [LicheePi Zero](https://wiki.sipeed.com/hardware/en/lichee/Zero/Zero.html) | ARMv7 (A7) | Allwinner V3s | 64MB | [Sipeed](https://sipeed.com/) |
+| 2019 | [Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) | ARM64 (A72) | BCM2711 | 1~8GB | [RPi](https://www.raspberrypi.com/) |
+| 2023 | [Raspberry Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/) | ARM64 (A76) | BCM2712 | 2~8GB | [RPi](https://www.raspberrypi.com/) |
+| 2024 | [LicheeRV-Nano](https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html) | RISC-V | SG2002 | 256MB | [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) |
+| 2024 | [MaixCAM-Pro](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | RISC-V | SG2002 | 256MB | [Sipeed](https://sipeed.com/) |
+| 2024 | [Milk-V Duo 64M](https://milkv.io/docs/duo/getting-started/duo) | RISC-V | CV1800B | 64MB | [Milk-V](https://milkv.io/) |
+| 2024 | [CanMV-K230](https://developer.canaan-creative.com/k230_canmv/en/main/) | RISC-V | K230 | 512MB | [Canaan](https://www.canaan-creative.com/) |
+
+---
+
+## 4. Fonctionne également sur
+
+### Téléphones Android (via Termux)
+
+Tout téléphone Android ARM64 (2015+) avec 1 Go+ de RAM. Installez [Termux](https://github.com/termux/termux-app), utilisez `proot` pour exécuter PicoClaw.
+
+> Voir [README : Exécuter sur d'anciens téléphones Android](../../README.fr.md#-run-on-old-android-phones) pour les instructions de configuration.
+
+### Bureau / Serveur / Cloud
+
+| Plateforme | Notes |
+|------------|-------|
+| x86_64 Linux | Binaire natif, aucune dépendance |
+| x86_64 Windows | Binaire natif |
+| macOS (Intel / Apple Silicon) | Binaire natif |
+| Docker (any platform) | `docker compose` en une ligne, voir [Guide Docker](docker.md) |
+| OpenWrt routers | Builds MIPS/ARM, nécessite >32 Mo de RAM libre |
+| FreeBSD / NetBSD | Builds x86_64 et arm64 disponibles |
+
+---
+
+## 5. Configuration minimale requise
+
+| Ressource | Minimum | Recommandé |
+|-----------|---------|------------|
+| RAM | 10 Mo libres | 32 Mo+ libres |
+| Stockage | 20 Mo (binaire) | 50 Mo+ (avec espace de travail) |
+| CPU | N'importe lequel (monocœur 0,6 GHz+) | — |
+| OS | Linux (kernel 3.x+) | Linux 5.x+ |
+| Réseau | Requis (pour les appels API LLM) | Ethernet ou WiFi |
+
+---
+
+## 6. Comment tester et contribuer
+
+```bash
+# 1. Télécharger pour votre architecture
+wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
+tar xzf picoclaw_Linux_arm64.tar.gz
+
+# 2. Initialiser
+./picoclaw onboard
+
+# 3. Tester
+./picoclaw agent -m "Hello, what board am I running on?"
+```
+
+Builds disponibles : `linux-amd64`, `linux-arm64`, `linux-arm`, `linux-riscv64`, `linux-loong64`, `linux-mipsle`
+
+### Ajouter votre matériel
+
+1. Forkez ce dépôt
+2. Ajoutez votre puce / produit / carte dans le tableau approprié
+3. Incluez : nom, architecture, SoC, RAM, année et un lien si disponible
+4. Soumettez une PR
+
+Fabricants de matériel : vous souhaitez ajouter un support officiel ou co-promouvoir ? Ouvrez une issue ou contactez-nous via [Discord](https://discord.gg/V4sAZ9XWpN).
diff --git a/docs/fr/providers.md b/docs/fr/providers.md
index b0b950a44..39f5cf36a 100644
--- a/docs/fr/providers.md
+++ b/docs/fr/providers.md
@@ -93,7 +93,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti
],
"agents": {
"defaults": {
- "model": "gpt-5.4"
+ "model_name": "gpt-5.4"
}
}
}
@@ -266,13 +266,13 @@ L'ancienne configuration `providers` est **dépréciée** mais toujours prise en
],
"agents": {
"defaults": {
- "model": "glm-4.7"
+ "model_name": "glm-4.7"
}
}
}
```
-Pour un guide de migration détaillé, voir [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
+Pour un guide de migration détaillé, voir [migration/model-list-migration.md](../migration/model-list-migration.md).
### Architecture des Fournisseurs
@@ -298,7 +298,7 @@ Cela maintient le runtime léger tout en faisant des nouveaux backends compatibl
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
+ "model_name": "glm-4.7",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
@@ -328,12 +328,11 @@ picoclaw agent -m "Hello"
{
"agents": {
"defaults": {
- "model": "anthropic/claude-opus-4-5"
+ "model_name": "anthropic/claude-opus-4-5"
}
},
"session": {
- "dm_scope": "per-channel-peer",
- "backlog_limit": 20
+ "dm_scope": "per-channel-peer"
},
"providers": {
"openrouter": {
diff --git a/docs/fr/troubleshooting.md b/docs/fr/troubleshooting.md
index bfe8901ef..d2d099ad3 100644
--- a/docs/fr/troubleshooting.md
+++ b/docs/fr/troubleshooting.md
@@ -16,7 +16,7 @@
**Correction :** Dans `~/.picoclaw/config.json` (ou votre chemin de configuration) :
-1. **agents.defaults.model** doit correspondre à un `model_name` dans `model_list` (par ex. `"openrouter-free"`).
+1. **agents.defaults.model_name** doit correspondre à un `model_name` dans `model_list` (par ex. `"openrouter-free"`).
2. Le **model** de cette entrée doit être un identifiant de modèle OpenRouter valide, par exemple :
- `"openrouter/free"` – niveau gratuit automatique
- `"google/gemini-2.0-flash-exp:free"`
@@ -28,7 +28,7 @@ Exemple :
{
"agents": {
"defaults": {
- "model": "openrouter-free"
+ "model_name": "openrouter-free"
}
},
"model_list": [
diff --git a/docs/hardware-compatibility.md b/docs/hardware-compatibility.md
new file mode 100644
index 000000000..c11849822
--- /dev/null
+++ b/docs/hardware-compatibility.md
@@ -0,0 +1,150 @@
+# 🖥️ PicoClaw Hardware Compatibility List
+
+PicoClaw runs on virtually any Linux device. This page tracks verified chips, products, and development boards.
+
+**Your hardware not listed?** Submit a PR to add it! Hardware vendors are welcome to contribute and co-promote.
+
+---
+
+## 1. Verified Chip Support
+
+### x86
+
+| Vendor | Chip | Notes |
+|--------|------|-------|
+| Intel | Any x86 CPU (i386+) | All desktop/server/laptop processors |
+| AMD | Any x86 CPU | All desktop/server/laptop processors |
+
+### ARM
+
+| Sub-arch | Typical Chips | Notes |
+|----------|--------------|-------|
+| ARMv6 | [BCM2835](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2835) (Raspberry Pi 1/Zero) | Single-core ARM1176JZF-S |
+| ARMv7 | [Allwinner V3s](https://linux-sunxi.org/V3s) | Single-core Cortex-A7, used in LicheePi Zero |
+| ARM64 | [Allwinner H618](https://linux-sunxi.org/H618) | Quad-core Cortex-A53, used in Orange Pi Zero 3 |
+| ARM64 | [BCM2711](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2711) (Raspberry Pi 4) | Quad-core Cortex-A72 |
+| ARM64 | [BCM2712](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2712) (Raspberry Pi 5) | Quad-core Cortex-A76 |
+| ARM64 | [AX630C](https://www.axera-tech.com/) (爱芯元智) | Dual-core Cortex-A53 + NPU, used in NanoKVM-Pro / MaixCAM2 |
+
+### RISC-V (riscv64)
+
+| Vendor | Chip | Core | Notes |
+|--------|------|------|-------|
+| [SOPHGO (算能)](https://www.sophgo.com/) | SG2002 | C906 @ 1GHz | 256MB DDR3 on-chip, used in LicheeRV-Nano / NanoKVM / MaixCAM |
+| [Allwinner (全志)](https://www.allwinnertech.com/) | V861 | Dual C907 | 128MB DDR3L on-chip, 1 TOPS NPU, 4K AI camera SiP |
+| [Allwinner (全志)](https://www.allwinnertech.com/) | V881 | C907 | RISC-V AI camera series |
+| [Arterytek (匠芯创)](https://www.arterytek.com/) | D213 | RISC-V | Used in HaaS506-LD1 industrial RTU |
+| [SpacemiT (进迭)](https://www.spacemit.com/) | K1 | 8x X60 @ 1.8GHz | Used in Milk-V Jupiter, BananaPi BPI-F3 |
+| [SpacemiT (进迭)](https://www.spacemit.com/) | K3 | 8x X100 @ 2.5GHz | RVA23 compliant, 1024-bit RVV, FP8 AI inference |
+| [Zhihe (知合)](https://www.zhihe-tech.com/) | A210 | High-perf RISC-V | 8-core, 16MB L3 cache, desktop-class |
+| [Canaan (嘉楠)](https://www.canaan-creative.com/) | K230 | Dual C908 @ 1.6GHz | 6 TOPS KPU, used in CanMV-K230 |
+
+### MIPS
+
+| Vendor | Chip | Notes |
+|--------|------|-------|
+| MediaTek | [MT7620](https://www.mediatek.com/products/home-networking/mt7620) | MIPS24KEc @ 580MHz, used in many OpenWrt routers (e.g. Xiaomi Router 3G) |
+
+### LoongArch (loong64)
+
+| Vendor | Chip | Notes |
+|--------|------|-------|
+| [Loongson (龙芯)](https://www.loongson.cn/) | 3A5000 | Quad-core LA464 @ 2.5GHz, desktop/workstation |
+| [Loongson (龙芯)](https://www.loongson.cn/) | 3A6000 | Quad-core 4C/8T @ 2.5GHz, IPC comparable to Intel 10th gen |
+| [Loongson (龙芯)](https://www.loongson.cn/) | 2K1000LA | Dual-core @ 1GHz, industrial/IoT applications |
+
+---
+
+## 2. Verified Products (by release date)
+
+Consumer products, routers, and industrial devices that have been tested with PicoClaw.
+
+| Year | Product | Arch | SoC | RAM | Category |
+|------|---------|------|-----|-----|----------|
+| 2009 | Nokia N900 | ARM (A8) | OMAP3430 | 256MB | Smartphone |
+| 2012 | Samsung Galaxy Note 10.1 (N8000) | ARM (A9) | Exynos 4412 | 2GB | Tablet |
+| 2016 | Xiaomi Router 3G (小米路由器3G) | MIPS | MT7620 | 256MB | Router (OpenWrt) |
+| 2018 | Phicomm N1 (斐讯N1) | ARM64 (A53) | S905D | 2GB | TV Box / Home Server |
+| 2019 | Xiaomi AI Speaker (小爱音箱) | ARM64 (A53) | — | 256MB | Smart Speaker |
+| 2024 | [NanoKVM](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html) | RISC-V | SG2002 | 256MB | IP-KVM |
+| 2025 | HaaS506-LD1 | RISC-V | D213 | 128MB | Industrial RTU |
+| 2025 | [NanoKVM-Pro](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM_Pro/introduction.html) | ARM64 (A53) | AX630C | 1GB | Pro IP-KVM |
+| 2026 | [MaixCAM2](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | ARM64 (A53) | AX630C | 1/4GB | 4K AI Camera |
+
+---
+
+## 3. Verified Development Boards (by release date)
+
+| Year | Board | Arch | SoC | RAM | Buy Link |
+|------|-------|------|-----|-----|----------|
+| 2012 | [Raspberry Pi 1 Model B](https://www.raspberrypi.com/products/) | ARMv6 | BCM2835 | 512MB | — |
+| 2015 | [Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) | ARMv7 (A7) | BCM2836 | 1GB | — |
+| 2015 | [Raspberry Pi Zero](https://www.raspberrypi.com/products/raspberry-pi-zero/) | ARMv6 | BCM2835 | 512MB | — |
+| 2016 | [Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) | ARM64 (A53) | BCM2837 | 1GB | — |
+| 2017 | [LicheePi Zero](https://wiki.sipeed.com/hardware/en/lichee/Zero/Zero.html) | ARMv7 (A7) | Allwinner V3s | 64MB | [Sipeed](https://sipeed.com/) |
+| 2019 | [Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) | ARM64 (A72) | BCM2711 | 1~8GB | [RPi](https://www.raspberrypi.com/) |
+| 2023 | [Raspberry Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/) | ARM64 (A76) | BCM2712 | 2~8GB | [RPi](https://www.raspberrypi.com/) |
+| 2024 | [LicheeRV-Nano](https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html) | RISC-V | SG2002 | 256MB | [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) |
+| 2024 | [MaixCAM-Pro](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | RISC-V | SG2002 | 256MB | [Sipeed](https://sipeed.com/) |
+| 2024 | [Milk-V Duo 64M](https://milkv.io/docs/duo/getting-started/duo) | RISC-V | CV1800B | 64MB | [Milk-V](https://milkv.io/) |
+| 2024 | [CanMV-K230](https://developer.canaan-creative.com/k230_canmv/en/main/) | RISC-V | K230 | 512MB | [Canaan](https://www.canaan-creative.com/) |
+
+---
+
+## 4. Also Works On
+
+### Android Phones (via Termux)
+
+Any ARM64 Android phone (2015+) with 1GB+ RAM. Install [Termux](https://github.com/termux/termux-app), use `proot` to run PicoClaw.
+
+> See [README: Run on old Android Phones](../README.md#-run-on-old-android-phones) for setup instructions.
+
+### Desktop / Server / Cloud
+
+| Platform | Notes |
+|----------|-------|
+| x86_64 Linux | Native binary, no dependencies |
+| x86_64 Windows | Native binary |
+| macOS (Intel / Apple Silicon) | Native binary |
+| Docker (any platform) | `docker compose` one-liner, see [Docker Guide](docker.md) |
+| OpenWrt routers | MIPS/ARM builds, requires >32MB free RAM |
+| FreeBSD / NetBSD | x86_64 and arm64 builds available |
+
+---
+
+## 5. Minimum Requirements
+
+| Resource | Minimum | Recommended |
+|----------|---------|-------------|
+| RAM | 10MB free | 32MB+ free |
+| Storage | 20MB (binary) | 50MB+ (with workspace) |
+| CPU | Any (single core 0.6GHz+) | — |
+| OS | Linux (kernel 3.x+) | Linux 5.x+ |
+| Network | Required (for LLM API calls) | Ethernet or WiFi |
+
+---
+
+## 6. How to Test & Contribute
+
+```bash
+# 1. Download for your architecture
+wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
+tar xzf picoclaw_Linux_arm64.tar.gz
+
+# 2. Initialize
+./picoclaw onboard
+
+# 3. Test
+./picoclaw agent -m "Hello, what board am I running on?"
+```
+
+Available builds: `linux-amd64`, `linux-arm64`, `linux-arm`, `linux-riscv64`, `linux-loong64`, `linux-mipsle`
+
+### Add Your Hardware
+
+1. Fork this repo
+2. Add your chip / product / board to the appropriate table
+3. Include: name, arch, SoC, RAM, year, and a link if available
+4. Submit a PR
+
+Hardware vendors: want to add official support or co-promote? Open an issue or reach out via [Discord](https://discord.gg/V4sAZ9XWpN).
diff --git a/docs/ja/ANTIGRAVITY_AUTH.md b/docs/ja/ANTIGRAVITY_AUTH.md
new file mode 100644
index 000000000..b55e4ab1b
--- /dev/null
+++ b/docs/ja/ANTIGRAVITY_AUTH.md
@@ -0,0 +1,809 @@
+> [README](../../README.ja.md) に戻る
+
+# Antigravity 認証・統合ガイド
+
+## 概要
+
+**Antigravity**(Google Cloud Code Assist)は、Google が提供する AI モデルプロバイダーで、Google のクラウドインフラストラクチャを通じて Claude Opus 4.6 や Gemini などのモデルへのアクセスを提供します。本ドキュメントでは、認証の仕組み、モデルの取得方法、PicoClaw での新しいプロバイダーの実装方法について完全なガイドを提供します。
+
+---
+
+## 目次
+
+1. [認証フロー](#認証フロー)
+2. [OAuth 実装の詳細](#oauth-実装の詳細)
+3. [トークン管理](#トークン管理)
+4. [モデルリストの取得](#モデルリストの取得)
+5. [使用量トラッキング](#使用量トラッキング)
+6. [プロバイダープラグイン構造](#プロバイダープラグイン構造)
+7. [統合要件](#統合要件)
+8. [API エンドポイント](#api-エンドポイント)
+9. [設定](#設定)
+10. [PicoClaw での新しいプロバイダーの作成](#picoclaw-での新しいプロバイダーの作成)
+
+---
+
+## 認証フロー
+
+### 1. PKCE 付き OAuth 2.0
+
+Antigravity はセキュアな認証のために **OAuth 2.0 with PKCE(Proof Key for Code Exchange)** を使用します:
+
+```
+┌─────────────┐ ┌─────────────────┐
+│ Client │ ───(1) Generate PKCE Pair────────> │ │
+│ │ ───(2) Open Auth URL─────────────> │ Google OAuth │
+│ │ │ Server │
+│ │ <──(3) Redirect with Code───────── │ │
+│ │ └─────────────────┘
+│ │ ───(4) Exchange Code for Tokens──> │ Token URL │
+│ │ │ │
+│ │ <──(5) Access + Refresh Tokens──── │ │
+└─────────────┘ └─────────────────┘
+```
+
+### 2. 詳細手順
+
+#### ステップ 1:PKCE パラメータの生成
+```typescript
+function generatePkce(): { verifier: string; challenge: string } {
+ const verifier = randomBytes(32).toString("hex");
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
+ return { verifier, challenge };
+}
+```
+
+#### ステップ 2:認可 URL の構築
+```typescript
+const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
+const REDIRECT_URI = "http://localhost:51121/oauth-callback";
+
+function buildAuthUrl(params: { challenge: string; state: string }): string {
+ const url = new URL(AUTH_URL);
+ url.searchParams.set("client_id", CLIENT_ID);
+ url.searchParams.set("response_type", "code");
+ url.searchParams.set("redirect_uri", REDIRECT_URI);
+ url.searchParams.set("scope", SCOPES.join(" "));
+ url.searchParams.set("code_challenge", params.challenge);
+ url.searchParams.set("code_challenge_method", "S256");
+ url.searchParams.set("state", params.state);
+ url.searchParams.set("access_type", "offline");
+ url.searchParams.set("prompt", "consent");
+ return url.toString();
+}
+```
+
+**必要なスコープ:**
+```typescript
+const SCOPES = [
+ "https://www.googleapis.com/auth/cloud-platform",
+ "https://www.googleapis.com/auth/userinfo.email",
+ "https://www.googleapis.com/auth/userinfo.profile",
+ "https://www.googleapis.com/auth/cclog",
+ "https://www.googleapis.com/auth/experimentsandconfigs",
+];
+```
+
+#### ステップ 3:OAuth コールバックの処理
+
+**自動モード(ローカル開発):**
+- ポート 51121 でローカル HTTP サーバーを起動
+- Google からのリダイレクトを待機
+- クエリパラメータから認可コードを抽出
+
+**手動モード(リモート/ヘッドレス):**
+- ユーザーに認可 URL を表示
+- ユーザーがブラウザで認証を完了
+- ユーザーが完全なリダイレクト URL をターミナルに貼り付け
+- 貼り付けられた URL からコードを解析
+
+#### ステップ 4:コードをトークンに交換
+```typescript
+const TOKEN_URL = "https://oauth2.googleapis.com/token";
+
+async function exchangeCode(params: {
+ code: string;
+ verifier: string;
+}): Promise<{ access: string; refresh: string; expires: number }> {
+ const response = await fetch(TOKEN_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ client_id: CLIENT_ID,
+ client_secret: CLIENT_SECRET,
+ code: params.code,
+ grant_type: "authorization_code",
+ redirect_uri: REDIRECT_URI,
+ code_verifier: params.verifier,
+ }),
+ });
+
+ const data = await response.json();
+
+ return {
+ access: data.access_token,
+ refresh: data.refresh_token,
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer
+ };
+}
+```
+
+#### ステップ 5:追加のユーザーデータの取得
+
+**ユーザーメール:**
+```typescript
+async function fetchUserEmail(accessToken: string): Promise {
+ const response = await fetch(
+ "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
+ { headers: { Authorization: `Bearer ${accessToken}` } }
+ );
+ const data = await response.json();
+ return data.email;
+}
+```
+
+**プロジェクト ID(API 呼び出しに必須):**
+```typescript
+async function fetchProjectId(accessToken: string): Promise {
+ const headers = {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "google-api-nodejs-client/9.15.1",
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+ "Client-Metadata": JSON.stringify({
+ ideType: "IDE_UNSPECIFIED",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ }),
+ };
+
+ const response = await fetch(
+ "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ metadata: {
+ ideType: "IDE_UNSPECIFIED",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ },
+ }),
+ }
+ );
+
+ const data = await response.json();
+ return data.cloudaicompanionProject || "rising-fact-p41fc"; // デフォルトのフォールバック
+}
+```
+
+---
+
+## OAuth 実装の詳細
+
+### クライアント認証情報
+
+**重要:** これらは pi-ai との同期のためにソースコード内で base64 エンコードされています:
+
+```typescript
+const decode = (s: string) => Buffer.from(s, "base64").toString();
+
+const CLIENT_ID = decode(
+ "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ=="
+);
+const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
+```
+
+### OAuth フローモード
+
+1. **自動フロー**(ブラウザのあるローカルマシン):
+ - ブラウザを自動的に開く
+ - ローカルコールバックサーバーがリダイレクトをキャプチャ
+ - 初回認証後はユーザー操作不要
+
+2. **手動フロー**(リモート/ヘッドレス/WSL2):
+ - 手動コピー&ペースト用の URL を表示
+ - ユーザーが外部ブラウザで認証を完了
+ - ユーザーが完全なリダイレクト URL を貼り付け
+
+```typescript
+function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
+ return isRemote || isWSL2Sync();
+}
+```
+
+---
+
+## トークン管理
+
+### 認証プロファイル構造
+
+```typescript
+type OAuthCredential = {
+ type: "oauth";
+ provider: "google-antigravity";
+ access: string; // アクセストークン
+ refresh: string; // リフレッシュトークン
+ expires: number; // 有効期限タイムスタンプ(エポックからのミリ秒)
+ email?: string; // ユーザーメール
+ projectId?: string; // Google Cloud プロジェクト ID
+};
+```
+
+### トークンの更新
+
+認証情報にはリフレッシュトークンが含まれており、現在のアクセストークンが期限切れになった際に新しいアクセストークンを取得するために使用できます。有効期限は競合状態を防ぐために 5 分のバッファを設けています。
+
+---
+
+## モデルリストの取得
+
+### 利用可能なモデルの取得
+
+```typescript
+const BASE_URL = "https://cloudcode-pa.googleapis.com";
+
+async function fetchAvailableModels(
+ accessToken: string,
+ projectId: string
+): Promise {
+ const headers = {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "antigravity",
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+ };
+
+ const response = await fetch(
+ `${BASE_URL}/v1internal:fetchAvailableModels`,
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ project: projectId }),
+ }
+ );
+
+ const data = await response.json();
+
+ // クォータ情報付きのモデルを返す
+ return Object.entries(data.models).map(([modelId, modelInfo]) => ({
+ id: modelId,
+ displayName: modelInfo.displayName,
+ quotaInfo: {
+ remainingFraction: modelInfo.quotaInfo?.remainingFraction,
+ resetTime: modelInfo.quotaInfo?.resetTime,
+ isExhausted: modelInfo.quotaInfo?.isExhausted,
+ },
+ }));
+}
+```
+
+### レスポンス形式
+
+```typescript
+type FetchAvailableModelsResponse = {
+ models?: Record;
+};
+```
+
+---
+
+## 使用量トラッキング
+
+### 使用量データの取得
+
+```typescript
+export async function fetchAntigravityUsage(
+ token: string,
+ timeoutMs: number
+): Promise {
+ // 1. クレジットとプラン情報を取得
+ const loadCodeAssistRes = await fetch(
+ `${BASE_URL}/v1internal:loadCodeAssist`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ metadata: {
+ ideType: "ANTIGRAVITY",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ },
+ }),
+ }
+ );
+
+ // クレジット情報を抽出
+ const { availablePromptCredits, planInfo, currentTier } = data;
+
+ // 2. モデルクォータを取得
+ const modelsRes = await fetch(
+ `${BASE_URL}/v1internal:fetchAvailableModels`,
+ {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}` },
+ body: JSON.stringify({ project: projectId }),
+ }
+ );
+
+ // 使用量ウィンドウを構築
+ return {
+ provider: "google-antigravity",
+ displayName: "Google Antigravity",
+ windows: [
+ { label: "Credits", usedPercent: calculateUsedPercent(available, monthly) },
+ // 個別モデルクォータ...
+ ],
+ plan: currentTier?.name || planType,
+ };
+}
+```
+
+### 使用量レスポンス構造
+
+```typescript
+type ProviderUsageSnapshot = {
+ provider: "google-antigravity";
+ displayName: string;
+ windows: UsageWindow[];
+ plan?: string;
+ error?: string;
+};
+
+type UsageWindow = {
+ label: string; // "Credits" またはモデル ID
+ usedPercent: number; // 0-100
+ resetAt?: number; // クォータがリセットされるタイムスタンプ
+};
+```
+
+---
+
+## プロバイダープラグイン構造
+
+### プラグイン定義
+
+```typescript
+const antigravityPlugin = {
+ id: "google-antigravity-auth",
+ name: "Google Antigravity Auth",
+ description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
+ configSchema: emptyPluginConfigSchema(),
+
+ register(api: PicoClawPluginApi) {
+ api.registerProvider({
+ id: "google-antigravity",
+ label: "Google Antigravity",
+ docsPath: "/providers/models",
+ aliases: ["antigravity"],
+
+ auth: [
+ {
+ id: "oauth",
+ label: "Google OAuth",
+ hint: "PKCE + localhost callback",
+ kind: "oauth",
+ run: async (ctx: ProviderAuthContext) => {
+ // OAuth 実装はここに記述
+ },
+ },
+ ],
+ });
+ },
+};
+```
+
+### ProviderAuthContext
+
+```typescript
+type ProviderAuthContext = {
+ config: PicoClawConfig;
+ agentDir?: string;
+ workspaceDir?: string;
+ prompter: WizardPrompter; // UI プロンプト/通知
+ runtime: RuntimeEnv; // ログなど
+ isRemote: boolean; // リモート実行かどうか
+ openUrl: (url: string) => Promise; // ブラウザオープナー
+ oauth: {
+ createVpsAwareHandlers: Function;
+ };
+};
+```
+
+### ProviderAuthResult
+
+```typescript
+type ProviderAuthResult = {
+ profiles: Array<{
+ profileId: string;
+ credential: AuthProfileCredential;
+ }>;
+ configPatch?: Partial;
+ defaultModel?: string;
+ notes?: string[];
+};
+```
+
+---
+
+## 統合要件
+
+### 1. 必要な環境/依存関係
+
+- Go ≥ 1.25
+- PicoClaw コードベース(`pkg/providers/` および `pkg/auth/`)
+- `crypto` および `net/http` 標準ライブラリパッケージ
+
+### 2. API 呼び出しに必要なヘッダー
+
+```typescript
+const REQUIRED_HEADERS = {
+ "Authorization": `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "antigravity", // または "google-api-nodejs-client/9.15.1"
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+};
+
+// loadCodeAssist 呼び出しには以下も含める:
+const CLIENT_METADATA = {
+ ideType: "ANTIGRAVITY", // または "IDE_UNSPECIFIED"
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+};
+```
+
+### 3. モデルスキーマのサニタイズ
+
+Antigravity は Gemini 互換モデルを使用するため、ツールスキーマのサニタイズが必要です:
+
+```typescript
+const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
+ "patternProperties",
+ "additionalProperties",
+ "$schema",
+ "$id",
+ "$ref",
+ "$defs",
+ "definitions",
+ "examples",
+ "minLength",
+ "maxLength",
+ "minimum",
+ "maximum",
+ "multipleOf",
+ "pattern",
+ "format",
+ "minItems",
+ "maxItems",
+ "uniqueItems",
+ "minProperties",
+ "maxProperties",
+]);
+
+// 送信前にスキーマをクリーンアップ
+function cleanToolSchemaForGemini(schema: Record): unknown {
+ // サポートされていないキーワードを削除
+ // トップレベルに type: "object" があることを確認
+ // anyOf/oneOf ユニオンをフラット化
+}
+```
+
+### 4. 思考ブロックの処理(Claude モデル)
+
+Antigravity の Claude モデルでは、思考ブロックに特別な処理が必要です:
+
+```typescript
+const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/;
+
+export function sanitizeAntigravityThinkingBlocks(
+ messages: AgentMessage[]
+): AgentMessage[] {
+ // 思考シグネチャを検証
+ // シグネチャフィールドを正規化
+ // 署名されていない思考ブロックを破棄
+}
+```
+
+---
+
+## API エンドポイント
+
+### 認証エンドポイント
+
+| エンドポイント | メソッド | 用途 |
+|---------------|---------|------|
+| `https://accounts.google.com/o/oauth2/v2/auth` | GET | OAuth 認可 |
+| `https://oauth2.googleapis.com/token` | POST | トークン交換 |
+| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | ユーザー情報(メール) |
+
+### Cloud Code Assist エンドポイント
+
+| エンドポイント | メソッド | 用途 |
+|---------------|---------|------|
+| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | プロジェクト情報、クレジット、プランの読み込み |
+| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | クォータ付き利用可能モデルの一覧 |
+| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | チャットストリーミングエンドポイント |
+
+**API リクエスト形式(チャット):**
+`v1internal:streamGenerateContent` エンドポイントは、標準の Gemini リクエストをラップするエンベロープ形式を期待します:
+
+```json
+{
+ "project": "your-project-id",
+ "model": "model-id",
+ "request": {
+ "contents": [...],
+ "systemInstruction": {...},
+ "generationConfig": {...},
+ "tools": [...]
+ },
+ "requestType": "agent",
+ "userAgent": "antigravity",
+ "requestId": "agent-timestamp-random"
+}
+```
+
+**API レスポンス形式(SSE):**
+各 SSE メッセージ(`data: {...}`)は `response` フィールドでラップされます:
+
+```json
+{
+ "response": {
+ "candidates": [...],
+ "usageMetadata": {...},
+ "modelVersion": "...",
+ "responseId": "..."
+ },
+ "traceId": "...",
+ "metadata": {}
+}
+```
+
+---
+
+## 設定
+
+### config.json の設定
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gemini-flash",
+ "model": "antigravity/gemini-3-flash",
+ "auth_method": "oauth"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model_name": "gemini-flash"
+ }
+ }
+}
+```
+
+### 認証プロファイルの保存
+
+認証プロファイルは `~/.picoclaw/auth.json` に保存されます:
+
+```json
+{
+ "credentials": {
+ "google-antigravity": {
+ "access_token": "ya29...",
+ "refresh_token": "1//...",
+ "expires_at": "2026-01-01T00:00:00Z",
+ "provider": "google-antigravity",
+ "auth_method": "oauth",
+ "email": "user@example.com",
+ "project_id": "my-project-id"
+ }
+ }
+}
+```
+
+---
+
+## PicoClaw での新しいプロバイダーの作成
+
+PicoClaw のプロバイダーは `pkg/providers/` 配下の Go パッケージとして実装されます。新しいプロバイダーを追加するには:
+
+### ステップバイステップの実装
+
+#### 1. プロバイダーファイルの作成
+
+`pkg/providers/` に新しい Go ファイルを作成します:
+
+```
+pkg/providers/
+└── your_provider.go
+```
+
+#### 2. Provider インターフェースの実装
+
+プロバイダーは `pkg/providers/types.go` で定義された `Provider` インターフェースを実装する必要があります:
+
+```go
+package providers
+
+type YourProvider struct {
+ apiKey string
+ apiBase string
+}
+
+func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider {
+ if apiBase == "" {
+ apiBase = "https://api.your-provider.com/v1"
+ }
+ return &YourProvider{apiKey: apiKey, apiBase: apiBase}
+}
+
+func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error {
+ // ストリーミング付きチャット補完を実装
+}
+```
+
+#### 3. ファクトリーへの登録
+
+`pkg/providers/factory.go` のプロトコルスイッチにプロバイダーを追加します:
+
+```go
+case "your-provider":
+ return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil
+```
+
+#### 4. デフォルト設定の追加(オプション)
+
+`pkg/config/defaults.go` にデフォルトエントリを追加します:
+
+```go
+{
+ ModelName: "your-model",
+ Model: "your-provider/model-name",
+ APIKey: "",
+},
+```
+
+#### 5. 認証サポートの追加(オプション)
+
+プロバイダーが OAuth や特別な認証を必要とする場合、`cmd/picoclaw/internal/auth/helpers.go` にケースを追加します:
+
+```go
+case "your-provider":
+ authLoginYourProvider()
+```
+
+#### 6. `config.json` での設定
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "your-model",
+ "model": "your-provider/model-name",
+ "api_key": "your-api-key",
+ "api_base": "https://api.your-provider.com/v1"
+ }
+ ]
+}
+```
+
+---
+
+## 実装のテスト
+
+### CLI コマンド
+
+```bash
+# プロバイダーで認証
+picoclaw auth login --provider your-provider
+
+# モデルの一覧表示(Antigravity 用)
+picoclaw auth models
+
+# ゲートウェイの起動
+picoclaw gateway
+
+# 特定のモデルでエージェントを実行
+picoclaw agent -m "Hello" --model your-model
+```
+
+### テスト用環境変数
+
+```bash
+# デフォルトモデルの上書き
+export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model
+
+# プロバイダー設定の上書き
+export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]'
+```
+
+---
+
+## 参考資料
+
+- **ソースファイル:**
+ - `pkg/providers/antigravity_provider.go` - Antigravity プロバイダー実装
+ - `pkg/auth/oauth.go` - OAuth フロー実装
+ - `pkg/auth/store.go` - 認証情報ストレージ(`~/.picoclaw/auth.json`)
+ - `pkg/providers/factory.go` - プロバイダーファクトリーとプロトコルルーティング
+ - `pkg/providers/types.go` - プロバイダーインターフェース定義
+ - `cmd/picoclaw/internal/auth/helpers.go` - 認証 CLI コマンド
+
+- **ドキュメント:**
+ - `docs/ANTIGRAVITY_USAGE.md` - Antigravity 使用ガイド
+ - `docs/migration/model-list-migration.md` - 移行ガイド
+
+---
+
+## 注意事項
+
+1. **Google Cloud プロジェクト:** Antigravity は Google Cloud プロジェクトで Gemini for Google Cloud が有効になっている必要があります
+2. **クォータ:** Google Cloud プロジェクトのクォータを使用します(個別の課金ではありません)
+3. **モデルアクセス:** 利用可能なモデルは Google Cloud プロジェクトの設定に依存します
+4. **思考ブロック:** Antigravity 経由の Claude モデルは、署名付き思考ブロックの特別な処理が必要です
+5. **スキーマサニタイズ:** ツールスキーマはサポートされていない JSON Schema キーワードを削除するためにサニタイズが必要です
+
+---
+
+---
+
+## 一般的なエラー処理
+
+### 1. レート制限(HTTP 429)
+
+プロジェクト/モデルのクォータが枯渇すると、Antigravity は 429 エラーを返します。エラーレスポンスには通常、`details` フィールドに `quotaResetDelay` が含まれます。
+
+**429 エラーの例:**
+```json
+{
+ "error": {
+ "code": 429,
+ "message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.",
+ "status": "RESOURCE_EXHAUSTED",
+ "details": [
+ {
+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
+ "metadata": {
+ "quotaResetDelay": "4h30m28.060903746s"
+ }
+ }
+ ]
+ }
+}
+```
+
+### 2. 空のレスポンス(制限付きモデル)
+
+一部のモデルは利用可能モデルリストに表示されますが、空のレスポンスを返す場合があります(200 OK だが SSE ストリームが空)。これは通常、現在のプロジェクトに使用権限がないプレビュー版または制限付きモデルで発生します。
+
+**対処法:** 空のレスポンスをエラーとして扱い、そのモデルがプロジェクトに対して制限されているか無効である可能性があることをユーザーに通知します。
+
+---
+
+## トラブルシューティング
+
+### "Token expired"(トークン期限切れ)
+- OAuth トークンを更新:`picoclaw auth login --provider antigravity`
+
+### "Gemini for Google Cloud is not enabled"(Gemini for Google Cloud が有効になっていない)
+- Google Cloud Console で API を有効にしてください
+
+### "Project not found"(プロジェクトが見つからない)
+- Google Cloud プロジェクトで必要な API が有効になっていることを確認してください
+- 認証中にプロジェクト ID が正しく取得されているか確認してください
+
+### モデルがリストに表示されない
+- OAuth 認証が正常に完了したことを確認してください
+- 認証プロファイルストレージを確認:`~/.picoclaw/auth.json`
+- `picoclaw auth login --provider antigravity` を再実行してください
diff --git a/docs/ja/ANTIGRAVITY_USAGE.md b/docs/ja/ANTIGRAVITY_USAGE.md
new file mode 100644
index 000000000..c044c1970
--- /dev/null
+++ b/docs/ja/ANTIGRAVITY_USAGE.md
@@ -0,0 +1,72 @@
+> [README](../../README.ja.md) に戻る
+
+# PicoClaw で Antigravity プロバイダーを使用する
+
+このガイドでは、PicoClaw で **Antigravity**(Google Cloud Code Assist)プロバイダーをセットアップして使用する方法を説明します。
+
+## 前提条件
+
+1. Google アカウント。
+2. Google Cloud Code Assist が有効であること(通常「Gemini for Google Cloud」のオンボーディングから利用可能)。
+
+## 1. 認証
+
+Antigravity で認証するには、以下のコマンドを実行します:
+
+```bash
+picoclaw auth login --provider antigravity
+```
+
+### 手動認証(ヘッドレス/VPS)
+サーバー(Coolify/Docker)上で実行しており、`localhost` にアクセスできない場合は、以下の手順に従ってください:
+1. 上記のコマンドを実行します。
+2. 表示された URL をコピーし、ローカルブラウザで開きます。
+3. ログインを完了します。
+4. ブラウザが `localhost:51121` URL にリダイレクトされます(ページは読み込めません)。
+5. **ブラウザのアドレスバーからその最終 URL をコピーします**。
+6. **PicoClaw が待機しているターミナルにそれを貼り付けます**。
+
+PicoClaw が自動的に認証コードを抽出し、プロセスを完了します。
+
+## 2. モデルの管理
+
+### 利用可能なモデルの一覧
+プロジェクトがアクセスできるモデルとそのクォータを確認するには:
+
+```bash
+picoclaw auth models
+```
+
+### モデルの切り替え
+`~/.picoclaw/config.json` でデフォルトモデルを変更するか、CLI でオーバーライドできます:
+
+```bash
+# 単一コマンドでオーバーライド
+picoclaw agent -m "Hello" --model claude-opus-4-6-thinking
+```
+
+## 3. 実際の使用方法(Coolify/Docker)
+
+Coolify または Docker でデプロイしている場合、以下の手順でテストしてください:
+
+1. **環境変数**:
+ * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash`
+2. **認証の永続化**:
+ ローカルでログイン済みの場合、認証情報をサーバーにコピーできます:
+ ```bash
+ scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/
+ ```
+ *または*、ターミナルアクセスがある場合、サーバー上で `auth login` コマンドを一度実行してください。
+
+## 4. トラブルシューティング
+
+* **空のレスポンス**:モデルが空の応答を返す場合、プロジェクトで制限されている可能性があります。`gemini-3-flash` または `claude-opus-4-6-thinking` を試してください。
+* **429 レート制限**:Antigravity には厳格なクォータがあります。制限に達した場合、PicoClaw はエラーメッセージに「リセット時間」を表示します。
+* **404 Not Found**:`picoclaw auth models` リストのモデル ID を使用していることを確認してください。フルパスではなく、短い ID(例:`gemini-3-flash`)を使用してください。
+
+## 5. 動作確認済みモデルのまとめ
+
+テストに基づき、以下のモデルが最も信頼性が高いです:
+* `gemini-3-flash`(高速、高可用性)
+* `gemini-2.5-flash-lite`(軽量)
+* `claude-opus-4-6-thinking`(高性能、推論機能を含む)
diff --git a/docs/ja/chat-apps.md b/docs/ja/chat-apps.md
index 54c6e4015..997a064ff 100644
--- a/docs/ja/chat-apps.md
+++ b/docs/ja/chat-apps.md
@@ -12,19 +12,19 @@ PicoClaw は複数のチャットプラットフォームをサポートして
| チャネル | セットアップ難易度 | 特徴 | ドキュメント |
| -------------------- | ------------------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
-| **Telegram** | ⭐ 簡単 | 推奨、音声テキスト変換対応、ロングポーリング(公開 IP 不要) | [ドキュメント](../channels/telegram/README.zh.md) |
-| **Discord** | ⭐ 簡単 | Socket Mode、グループ/DM 対応、Bot エコシステム充実 | [ドキュメント](../channels/discord/README.zh.md) |
-| **WhatsApp** | ⭐ 簡単 | ネイティブ (QR スキャン) または Bridge URL | [ドキュメント](../channels/whatsapp/README.zh.md) |
-| **Slack** | ⭐ 簡単 | **Socket Mode** (公開 IP 不要)、エンタープライズ対応 | [ドキュメント](../channels/slack/README.zh.md) |
-| **Matrix** | ⭐⭐ 中程度 | フェデレーションプロトコル、セルフホスト対応 | [ドキュメント](../channels/matrix/README.zh.md) |
-| **QQ** | ⭐⭐ 中程度 | 公式ボット API、中国コミュニティ向け | [ドキュメント](../channels/qq/README.zh.md) |
-| **DingTalk** | ⭐⭐ 中程度 | Stream モード(公開 IP 不要)、企業向け | [ドキュメント](../channels/dingtalk/README.zh.md) |
-| **LINE** | ⭐⭐⭐ やや難 | HTTPS Webhook が必要 | [ドキュメント](../channels/line/README.zh.md) |
-| **WeCom (企業微信)** | ⭐⭐⭐ やや難 | グループ Bot (Webhook)、カスタムアプリ (API)、AI Bot 対応 | [Bot](../channels/wecom/wecom_bot/README.zh.md) / [App](../channels/wecom/wecom_app/README.zh.md) / [AI Bot](../channels/wecom/wecom_aibot/README.zh.md) |
-| **Feishu (飛書)** | ⭐⭐⭐ やや難 | エンタープライズコラボレーション、機能豊富 | [ドキュメント](../channels/feishu/README.zh.md) |
+| **Telegram** | ⭐ 簡単 | 推奨、音声テキスト変換対応、ロングポーリング(公開 IP 不要) | [ドキュメント](../channels/telegram/README.ja.md) |
+| **Discord** | ⭐ 簡単 | Socket Mode、グループ/DM 対応、Bot エコシステム充実 | [ドキュメント](../channels/discord/README.ja.md) |
+| **WhatsApp** | ⭐ 簡単 | ネイティブ (QR スキャン) または Bridge URL | [ドキュメント](#whatsapp) |
+| **Slack** | ⭐ 簡単 | **Socket Mode** (公開 IP 不要)、エンタープライズ対応 | [ドキュメント](../channels/slack/README.ja.md) |
+| **Matrix** | ⭐⭐ 中程度 | フェデレーションプロトコル、セルフホスト対応 | [ドキュメント](../channels/matrix/README.ja.md) |
+| **QQ** | ⭐⭐ 中程度 | 公式ボット API、中国コミュニティ向け | [ドキュメント](../channels/qq/README.ja.md) |
+| **DingTalk** | ⭐⭐ 中程度 | Stream モード(公開 IP 不要)、企業向け | [ドキュメント](../channels/dingtalk/README.ja.md) |
+| **LINE** | ⭐⭐⭐ やや難 | HTTPS Webhook が必要 | [ドキュメント](../channels/line/README.ja.md) |
+| **WeCom (企業微信)** | ⭐⭐⭐ やや難 | グループ Bot (Webhook)、カスタムアプリ (API)、AI Bot 対応 | [Bot](../channels/wecom/wecom_bot/README.ja.md) / [App](../channels/wecom/wecom_app/README.ja.md) / [AI Bot](../channels/wecom/wecom_aibot/README.ja.md) |
+| **Feishu (飛書)** | ⭐⭐⭐ やや難 | エンタープライズコラボレーション、機能豊富 | [ドキュメント](../channels/feishu/README.ja.md) |
| **IRC** | ⭐⭐ 中程度 | サーバー + TLS 設定 | - |
-| **OneBot** | ⭐⭐ 中程度 | NapCat/Go-CQHTTP 互換、コミュニティエコシステム充実 | [ドキュメント](../channels/onebot/README.zh.md) |
-| **MaixCam** | ⭐ 簡単 | Sipeed AI カメラハードウェア統合チャネル | [ドキュメント](../channels/maixcam/README.zh.md) |
+| **OneBot** | ⭐⭐ 中程度 | NapCat/Go-CQHTTP 互換、コミュニティエコシステム充実 | [ドキュメント](../channels/onebot/README.ja.md) |
+| **MaixCam** | ⭐ 簡単 | Sipeed AI カメラハードウェア統合チャネル | [ドキュメント](../channels/maixcam/README.ja.md) |
| **Pico** | ⭐ 簡単 | PicoClaw ネイティブプロトコルチャネル | |
---
@@ -207,12 +207,13 @@ picoclaw gateway
QQ
-**1. Bot を作成**
+**クイックセットアップ(推奨)**
-- [QQ 開放プラットフォーム](https://q.qq.com/#) にアクセス
-- アプリケーションを作成 → **AppID** と **AppSecret** を取得
+QQ 開放プラットフォームでは、OpenClaw 互換ボットのワンクリックセットアップページが提供されています:
-**2. 設定**
+1. [QQ Bot クイックスタート](https://q.qq.com/qqbot/openclaw/index.html) を開き、QR コードをスキャンしてログイン
+2. ボットが自動的に作成されます — **App ID** と **App Secret** をコピー
+3. PicoClaw を設定:
```json
{
@@ -227,13 +228,20 @@ picoclaw gateway
}
```
-> `allow_from` を空にするとすべてのユーザーを許可します。QQ 番号を指定してアクセスを制限することもできます。
+4. `picoclaw gateway` を実行し、QQ を開いてボットとチャット
-**3. 実行**
+> App Secret は一度しか表示されません。すぐに保存してください — 再度表示するとリセットされます。
+>
+> クイックセットアップで作成されたボットは、最初は作成者のみが使用でき、グループチャットには対応していません。グループアクセスを有効にするには、[QQ 開放プラットフォーム](https://q.qq.com/) でサンドボックスモードを設定してください。
-```bash
-picoclaw gateway
-```
+**手動セットアップ**
+
+ボットを手動で作成する場合:
+
+* [QQ 開放プラットフォーム](https://q.qq.com/) にログインして開発者登録
+* QQ ボットを作成 — アバターと名前をカスタマイズ
+* ボット設定から **App ID** と **App Secret** をコピー
+* 上記の設定を行い、`picoclaw gateway` を実行
@@ -242,9 +250,10 @@ picoclaw gateway
**1. Slack App を作成**
-* [Slack API](https://api.slack.com/apps) でアプリを作成
-* **Socket Mode** を有効化
-* **Bot Token** と **App-Level Token** を取得
+* [Slack API](https://api.slack.com/apps) にアクセスして新しいアプリを作成
+* **OAuth & Permissions** で Bot スコープを追加:`chat:write`、`app_mentions:read`、`im:history`、`im:read`、`im:write`
+* アプリをワークスペースにインストール
+* **Bot Token**(`xoxb-...`)と **App-Level Token**(`xapp-...`、Socket Mode を有効にして取得)をコピー
**2. 設定**
@@ -253,8 +262,8 @@ picoclaw gateway
"channels": {
"slack": {
"enabled": true,
- "bot_token": "xoxb-YOUR_BOT_TOKEN",
- "app_token": "xapp-YOUR_APP_TOKEN",
+ "bot_token": "xoxb-YOUR-BOT-TOKEN",
+ "app_token": "xapp-YOUR-APP-TOKEN",
"allow_from": []
}
}
@@ -280,21 +289,26 @@ picoclaw gateway
"irc": {
"enabled": true,
"server": "irc.libera.chat:6697",
+ "tls": true,
"nick": "picoclaw-bot",
- "use_tls": true,
- "channels_to_join": ["#your-channel"],
+ "channels": ["#your-channel"],
+ "password": "",
"allow_from": []
}
}
}
```
+オプション:NickServ 認証用の `nickserv_password`、SASL 認証用の `sasl_user`/`sasl_password`。
+
**2. 実行**
```bash
picoclaw gateway
```
+ボットは IRC サーバーに接続し、指定されたチャネルに参加します。
+
@@ -382,11 +396,14 @@ picoclaw gateway
Feishu (飛書)
+PicoClaw は WebSocket/SDK モードで飛書に接続します — 公開 Webhook URL やコールバックサーバーは不要です。
+
**1. アプリを作成**
-* [飛書開放プラットフォーム](https://open.feishu.cn/) にアクセス
-* 企業カスタムアプリを作成
-* **App ID** と **App Secret** を取得
+* [飛書開放プラットフォーム](https://open.feishu.cn/) にアクセスしてアプリケーションを作成
+* アプリ設定で **ボット** 機能を有効化
+* バージョンを作成してアプリを公開(アプリは公開しないと有効になりません)
+* **App ID**(`cli_` で始まる)と **App Secret** をコピー
**2. 設定**
@@ -396,21 +413,25 @@ picoclaw gateway
"feishu": {
"enabled": true,
"app_id": "cli_xxx",
- "app_secret": "xxx",
- "encrypt_key": "",
- "verification_token": "",
+ "app_secret": "YOUR_APP_SECRET",
"allow_from": []
}
}
}
```
-**3. 実行**
+オプション:`encrypt_key` と `verification_token` でイベント暗号化(本番環境推奨)。
+
+**3. 実行してチャット**
```bash
picoclaw gateway
```
+飛書を開き、ボット名を検索してチャットを開始できます。ボットをグループに追加することもできます — `group_trigger.mention_only: true` を設定すると @メンション時のみ応答します。
+
+詳細なオプションについては [飛書チャネル設定ガイド](../channels/feishu/README.ja.md) を参照してください。
+
@@ -422,7 +443,7 @@ PicoClaw は 3 種類の WeCom 統合をサポートしています:
**方式 2: カスタムアプリ (App)** — より多機能、プロアクティブメッセージング、プライベートチャットのみ
**方式 3: AI Bot** — 公式 AI Bot、ストリーミング返信、グループ・プライベートチャット対応
-詳細なセットアップ手順は [WeCom AI Bot 設定ガイド](../channels/wecom/wecom_aibot/README.zh.md) を参照してください。
+詳細なセットアップ手順は [WeCom AI Bot 設定ガイド](../channels/wecom/wecom_aibot/README.ja.md) を参照してください。
**クイックセットアップ — グループ Bot:**
@@ -496,7 +517,7 @@ picoclaw gateway
**1. AI Bot を作成**
* WeCom 管理コンソール → アプリ管理 → AI Bot
-* AI Bot 設定でコールバック URL を設定:`http://your-server:18791/webhook/wecom-aibot`
+* AI Bot 設定でコールバック URL を設定:`http://your-server:18790/webhook/wecom-aibot`
* **Token** をコピーし、「ランダム生成」をクリックして **EncodingAESKey** を取得
**2. 設定**
@@ -528,24 +549,36 @@ picoclaw gateway
-OneBot
+OneBot(OneBot プロトコル経由の QQ)
-**1. 設定**
+OneBot は QQ ボット向けのオープンプロトコルです。PicoClaw は OneBot v11 互換の実装(例:[Lagrange](https://github.com/LagrangeDev/Lagrange.Core)、[NapCat](https://github.com/NapNeko/NapCatQQ))に WebSocket で接続します。
-NapCat / Go-CQHTTP などの OneBot 実装と互換性があります。
+**1. OneBot 実装をセットアップ**
+
+OneBot v11 互換の QQ ボットフレームワークをインストールして実行します。WebSocket サーバーを有効にしてください。
+
+**2. 設定**
```json
{
"channels": {
"onebot": {
"enabled": true,
+ "ws_url": "ws://127.0.0.1:8080",
+ "access_token": "",
"allow_from": []
}
}
}
```
-**2. 実行**
+| フィールド | 説明 |
+|-------|-------------|
+| `ws_url` | OneBot 実装の WebSocket URL |
+| `access_token` | 認証用アクセストークン(OneBot 側で設定している場合) |
+| `reconnect_interval` | 再接続間隔(秒)(デフォルト:5) |
+
+**3. 実行**
```bash
picoclaw gateway
diff --git a/docs/ja/configuration.md b/docs/ja/configuration.md
index c0f68f85b..215b35d54 100644
--- a/docs/ja/configuration.md
+++ b/docs/ja/configuration.md
@@ -57,7 +57,7 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw
1. `~/.picoclaw/workspace/skills`(ワークスペース)
2. `~/.picoclaw/skills`(グローバル)
-3. `/skills`(ビルトイン)
+3. `<ビルド時埋め込みパス>/skills`(ビルトイン)
高度な/テスト用セットアップでは、以下の環境変数でビルトインスキルのルートを上書きできます:
diff --git a/docs/ja/credential_encryption.md b/docs/ja/credential_encryption.md
new file mode 100644
index 000000000..ea74b65d2
--- /dev/null
+++ b/docs/ja/credential_encryption.md
@@ -0,0 +1,158 @@
+> [README](../../README.ja.md) に戻る
+
+# クレデンシャル暗号化
+
+PicoClaw は `model_list` 設定エントリの `api_key` 値の暗号化をサポートしています。
+暗号化されたキーは `enc://` 文字列として保存され、起動時に自動的に復号されます。
+
+---
+
+## クイックスタート
+
+**1. パスフレーズを設定する**
+
+```bash
+export PICOCLAW_KEY_PASSPHRASE="your-passphrase"
+```
+
+**2. API キーを暗号化する**
+
+`picoclaw onboard` を実行します — パスフレーズの入力を求められ、SSH キーが生成されます。
+その後、次の `SaveConfig` 呼び出し時に、設定内のすべての平文 `api_key` エントリが自動的に再暗号化されます。生成される `enc://` 値は以下のようになります:
+
+```
+enc://AAAA...base64...
+```
+
+**3. 出力を設定に貼り付ける**
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-4o",
+ "model": "openai/gpt-4o",
+ "api_key": "enc://AAAA...base64...",
+ "api_base": "https://api.openai.com/v1"
+ }
+ ]
+}
+```
+
+---
+
+## サポートされる `api_key` 形式
+
+| 形式 | 例 | 動作 |
+|------|---|------|
+| 平文 | `sk-abc123` | そのまま使用 |
+| ファイル参照 | `file://openai.key` | 設定ファイルと同じディレクトリから内容を読み取り |
+| 暗号化 | `enc://` | 起動時に `PICOCLAW_KEY_PASSPHRASE` を使用して復号 |
+| 空 | `""` | そのまま渡される(`auth_method: oauth` で使用) |
+
+---
+
+## 暗号設計
+
+### 鍵導出
+
+暗号化には **HKDF-SHA256** を使用し、SSH 秘密鍵を第二要素とします。
+
+```
+sshHash = SHA256(ssh_private_key_file_bytes)
+ikm = HMAC-SHA256(key=sshHash, message=passphrase)
+aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes)
+```
+
+### 暗号化
+
+```
+AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key)
+```
+
+### ワイヤーフォーマット
+
+```
+enc://
+```
+
+| フィールド | サイズ | 説明 |
+|-----------|--------|------|
+| `salt` | 16 バイト | 暗号化ごとにランダム生成;HKDF に入力 |
+| `nonce` | 12 バイト | 暗号化ごとにランダム生成;AES-GCM IV |
+| `ciphertext` | 可変 | AES-256-GCM 暗号文 + 16 バイト認証タグ |
+
+GCM 認証タグは暗号文に自動的に付加されます。改ざんがあった場合、破損した平文を返すのではなく、エラーで復号が失敗します。
+
+### パフォーマンス
+
+| 操作 | 所要時間 (ARM Cortex-A) |
+|------|------------------------|
+| 鍵導出 (HKDF) | < 1 ms |
+| AES-256-GCM 復号 | < 1 ms |
+| **起動時の総オーバーヘッド** | **キーあたり < 2 ms** |
+
+---
+
+## SSH キーによる二要素セキュリティ
+
+SSH 秘密鍵が提供されている場合、暗号を破るには**両方**が必要です:
+
+1. **パスフレーズ** (`PICOCLAW_KEY_PASSPHRASE`)
+2. **SSH 秘密鍵ファイル**
+
+これは、設定ファイルが漏洩しただけでは、パスフレーズが弱い場合でも API キーを復元できないことを意味します。SSH キーはパスフレーズの強度に関係なく、256 ビットのエントロピー(Ed25519)を提供します。
+
+### 脅威モデル
+
+| 攻撃者が持っているもの | 復号可能か? |
+|----------------------|-------------|
+| 設定ファイルのみ | いいえ — パスフレーズ + SSH キーが必要 |
+| SSH キーのみ | いいえ — パスフレーズが必要 |
+| パスフレーズのみ | いいえ — SSH キーが必要 |
+| 設定ファイル + SSH キー + パスフレーズ | はい — 完全な侵害 |
+
+---
+
+## 環境変数
+
+| 変数 | 必須 | 説明 |
+|------|------|------|
+| `PICOCLAW_KEY_PASSPHRASE` | はい(`enc://` 使用時) | 鍵導出に使用するパスフレーズ |
+| `PICOCLAW_SSH_KEY_PATH` | いいえ | SSH 秘密鍵のパス。未設定の場合、`~/.ssh/picoclaw_ed25519.key` から自動検出 |
+
+### SSH キーの自動検出
+
+`PICOCLAW_SSH_KEY_PATH` が設定されていない場合、PicoClaw は専用キーを探します:
+
+```
+~/.ssh/picoclaw_ed25519.key
+```
+
+この専用ファイルにより、ユーザーの既存の SSH キーとの競合を回避します。
+`picoclaw onboard` を実行すると自動的に生成されます。
+
+`os.UserHomeDir()` はクロスプラットフォームのホームディレクトリ解決に使用されます(Windows では `USERPROFILE`、Unix/macOS では `HOME` を読み取ります)。
+
+> **注意:** SSH キーファイルはクレデンシャル暗号化に必須です。キーが見つからず `PICOCLAW_SSH_KEY_PATH` も設定されていない場合、暗号化/復号は失敗します。`picoclaw onboard` を実行してキーを自動生成してください。
+
+---
+
+## 移行
+
+唯一の秘密情報は `PICOCLAW_KEY_PASSPHRASE` と SSH 秘密鍵ファイルであるため、移行は簡単です:
+
+1. 設定ファイルを新しいマシンにコピーします。
+2. `PICOCLAW_KEY_PASSPHRASE` を同じ値に設定します。
+3. SSH 秘密鍵ファイルを同じパスにコピーします(または `PICOCLAW_SSH_KEY_PATH` を新しい場所に設定します)。
+
+再暗号化は不要です。
+
+---
+
+## セキュリティに関する考慮事項
+
+- **パスフレーズと SSH キーの両方が必須です。** SSH キーは第二要素として機能します — これがなければ暗号化/復号は失敗します。キーが存在しない場合は `picoclaw onboard` を実行して生成してください。
+- **SSH キーは実行時に読み取り専用です。** PicoClaw は SSH キーファイルへの書き込みや変更を行いません。
+- **平文キーは引き続きサポートされます。** `enc://` を使用しない既存の設定は影響を受けません。
+- **`enc://` 形式はバージョン管理されています。** HKDF `info` フィールド(`picoclaw-credential-v1`)により、既存の暗号化値を壊すことなく将来のアルゴリズムアップグレードが可能です。
diff --git a/docs/ja/debug.md b/docs/ja/debug.md
new file mode 100644
index 000000000..ecc52f454
--- /dev/null
+++ b/docs/ja/debug.md
@@ -0,0 +1,36 @@
+# PicoClaw のデバッグ
+
+> [README](../../README.ja.md) に戻る
+
+PicoClaw は、受信するすべてのリクエストに対して、メッセージのルーティングや複雑度の評価、ツールの実行、モデル障害への適応など、多くの複雑な処理をバックグラウンドで実行しています。何が起きているかを正確に把握できることは、潜在的な問題のトラブルシューティングだけでなく、エージェントの動作を真に理解するためにも非常に重要です。
+
+## デバッグモードで PicoClaw を起動する
+
+エージェントの動作に関する詳細情報(LLM リクエスト、ツール呼び出し、メッセージルーティング)を取得するには、デバッグフラグを付けて PicoClaw ゲートウェイを起動します:
+
+```bash
+picoclaw gateway --debug
+# or
+picoclaw gateway -d
+```
+
+このモードでは、システムがログを詳細にフォーマットし、システムプロンプトやツール実行結果のプレビューを表示します。
+
+## ログの切り詰めを無効にする(完全なログ)
+
+デフォルトでは、PicoClaw はコンソールの可読性を保つために、デバッグログ内の非常に長い文字列(*システムプロンプト*や大きな JSON 出力結果など)を切り詰めます。
+
+コマンドの完全な出力や、LLM モデルに送信された正確なペイロードを確認する必要がある場合は、`--no-truncate` フラグを使用できます。
+
+**注意:** このフラグは `--debug` モードと組み合わせた場合に*のみ*機能します。
+
+```bash
+picoclaw gateway --debug --no-truncate
+
+```
+
+このフラグが有効な場合、グローバルな切り詰め機能が無効になります。これは以下の場合に非常に便利です:
+
+* プロバイダーに送信されるメッセージの正確な構文を確認する。
+* `exec`、`web_fetch`、`read_file` などのツールの完全な出力を読む。
+* メモリに保存されたセッション履歴をデバッグする。
diff --git a/docs/ja/docker.md b/docs/ja/docker.md
index 6ad55d41d..31ed17ec5 100644
--- a/docs/ja/docker.md
+++ b/docs/ja/docker.md
@@ -12,6 +12,7 @@ git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
# 2. 初回実行 — docker/data/config.json を自動生成して終了
+# (config.json と workspace/ の両方が存在しない場合のみ実行)
docker compose -f docker/docker-compose.yml --profile gateway up
# コンテナが "First-run setup complete." と表示して停止します
diff --git a/docs/ja/hardware-compatibility.md b/docs/ja/hardware-compatibility.md
new file mode 100644
index 000000000..96ccd1cd1
--- /dev/null
+++ b/docs/ja/hardware-compatibility.md
@@ -0,0 +1,152 @@
+> [README](../../README.ja.md) に戻る
+
+# 🖥️ PicoClaw ハードウェア互換性リスト
+
+PicoClaw はほぼすべての Linux デバイスで動作します。このページでは、検証済みのチップ、製品、開発ボードを記録しています。
+
+**お使いのハードウェアがリストにない場合は?** PR を送信して追加してください!ハードウェアベンダーの貢献と共同プロモーションを歓迎します。
+
+---
+
+## 1. 検証済みチップサポート
+
+### x86
+
+| ベンダー | チップ | 備考 |
+|----------|--------|------|
+| Intel | Any x86 CPU (i386+) | すべてのデスクトップ/サーバー/ノートPC プロセッサ |
+| AMD | Any x86 CPU | すべてのデスクトップ/サーバー/ノートPC プロセッサ |
+
+### ARM
+
+| サブアーキテクチャ | 代表的なチップ | 備考 |
+|--------------------|----------------|------|
+| ARMv6 | [BCM2835](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2835) (Raspberry Pi 1/Zero) | シングルコア ARM1176JZF-S |
+| ARMv7 | [Allwinner V3s](https://linux-sunxi.org/V3s) | シングルコア Cortex-A7、LicheePi Zero で使用 |
+| ARM64 | [Allwinner H618](https://linux-sunxi.org/H618) | クアッドコア Cortex-A53、Orange Pi Zero 3 で使用 |
+| ARM64 | [BCM2711](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2711) (Raspberry Pi 4) | クアッドコア Cortex-A72 |
+| ARM64 | [BCM2712](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2712) (Raspberry Pi 5) | クアッドコア Cortex-A76 |
+| ARM64 | [AX630C](https://www.axera-tech.com/) (爱芯元智) | デュアルコア Cortex-A53 + NPU、NanoKVM-Pro / MaixCAM2 で使用 |
+
+### RISC-V (riscv64)
+
+| ベンダー | チップ | コア | 備考 |
+|----------|--------|------|------|
+| [SOPHGO (算能)](https://www.sophgo.com/) | SG2002 | C906 @ 1GHz | 256MB DDR3 オンチップ、LicheeRV-Nano / NanoKVM / MaixCAM で使用 |
+| [Allwinner (全志)](https://www.allwinnertech.com/) | V861 | Dual C907 | 128MB DDR3L オンチップ、1 TOPS NPU、4K AI カメラ SiP |
+| [Allwinner (全志)](https://www.allwinnertech.com/) | V881 | C907 | RISC-V AI カメラシリーズ |
+| [Arterytek (匠芯创)](https://www.arterytek.com/) | D213 | RISC-V | HaaS506-LD1 産業用 RTU で使用 |
+| [SpacemiT (进迭)](https://www.spacemit.com/) | K1 | 8x X60 @ 1.8GHz | Milk-V Jupiter, BananaPi BPI-F3 で使用 |
+| [SpacemiT (进迭)](https://www.spacemit.com/) | K3 | 8x X100 @ 2.5GHz | RVA23 準拠、1024 ビット RVV、FP8 AI 推論 |
+| [Zhihe (知合)](https://www.zhihe-tech.com/) | A210 | High-perf RISC-V | 8 コア、16MB L3 キャッシュ、デスクトップクラス |
+| [Canaan (嘉楠)](https://www.canaan-creative.com/) | K230 | Dual C908 @ 1.6GHz | 6 TOPS KPU、CanMV-K230 で使用 |
+
+### MIPS
+
+| ベンダー | チップ | 備考 |
+|----------|--------|------|
+| MediaTek | [MT7620](https://www.mediatek.com/products/home-networking/mt7620) | MIPS24KEc @ 580MHz、多くの OpenWrt ルーターで使用(例:Xiaomi Router 3G) |
+
+### LoongArch (loong64)
+
+| ベンダー | チップ | 備考 |
+|----------|--------|------|
+| [Loongson (龙芯)](https://www.loongson.cn/) | 3A5000 | クアッドコア LA464 @ 2.5GHz、デスクトップ/ワークステーション |
+| [Loongson (龙芯)](https://www.loongson.cn/) | 3A6000 | クアッドコア 4C/8T @ 2.5GHz、IPC は Intel 第10世代に匹敵 |
+| [Loongson (龙芯)](https://www.loongson.cn/) | 2K1000LA | デュアルコア @ 1GHz、産業/IoT アプリケーション |
+
+---
+
+## 2. 検証済み製品(発売日順)
+
+PicoClaw でテスト済みのコンシューマー製品、ルーター、産業用デバイス。
+
+| 年 | 製品 | アーキテクチャ | SoC | RAM | カテゴリ |
+|----|------|----------------|-----|-----|----------|
+| 2009 | Nokia N900 | ARM (A8) | OMAP3430 | 256MB | スマートフォン |
+| 2012 | Samsung Galaxy Note 10.1 (N8000) | ARM (A9) | Exynos 4412 | 2GB | タブレット |
+| 2016 | Xiaomi Router 3G (小米路由器3G) | MIPS | MT7620 | 256MB | ルーター (OpenWrt) |
+| 2018 | Phicomm N1 (斐讯N1) | ARM64 (A53) | S905D | 2GB | TV ボックス / ホームサーバー |
+| 2019 | Xiaomi AI Speaker (小爱音箱) | ARM64 (A53) | — | 256MB | スマートスピーカー |
+| 2024 | [NanoKVM](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html) | RISC-V | SG2002 | 256MB | IP-KVM |
+| 2025 | HaaS506-LD1 | RISC-V | D213 | 128MB | 産業用 RTU |
+| 2025 | [NanoKVM-Pro](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM_Pro/introduction.html) | ARM64 (A53) | AX630C | 1GB | プロ IP-KVM |
+| 2026 | [MaixCAM2](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | ARM64 (A53) | AX630C | 1/4GB | 4K AI カメラ |
+
+---
+
+## 3. 検証済み開発ボード(発売日順)
+
+| 年 | ボード | アーキテクチャ | SoC | RAM | 購入リンク |
+|----|--------|----------------|-----|-----|------------|
+| 2012 | [Raspberry Pi 1 Model B](https://www.raspberrypi.com/products/) | ARMv6 | BCM2835 | 512MB | — |
+| 2015 | [Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) | ARMv7 (A7) | BCM2836 | 1GB | — |
+| 2015 | [Raspberry Pi Zero](https://www.raspberrypi.com/products/raspberry-pi-zero/) | ARMv6 | BCM2835 | 512MB | — |
+| 2016 | [Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) | ARM64 (A53) | BCM2837 | 1GB | — |
+| 2017 | [LicheePi Zero](https://wiki.sipeed.com/hardware/en/lichee/Zero/Zero.html) | ARMv7 (A7) | Allwinner V3s | 64MB | [Sipeed](https://sipeed.com/) |
+| 2019 | [Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) | ARM64 (A72) | BCM2711 | 1~8GB | [RPi](https://www.raspberrypi.com/) |
+| 2023 | [Raspberry Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/) | ARM64 (A76) | BCM2712 | 2~8GB | [RPi](https://www.raspberrypi.com/) |
+| 2024 | [LicheeRV-Nano](https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html) | RISC-V | SG2002 | 256MB | [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) |
+| 2024 | [MaixCAM-Pro](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | RISC-V | SG2002 | 256MB | [Sipeed](https://sipeed.com/) |
+| 2024 | [Milk-V Duo 64M](https://milkv.io/docs/duo/getting-started/duo) | RISC-V | CV1800B | 64MB | [Milk-V](https://milkv.io/) |
+| 2024 | [CanMV-K230](https://developer.canaan-creative.com/k230_canmv/en/main/) | RISC-V | K230 | 512MB | [Canaan](https://www.canaan-creative.com/) |
+
+---
+
+## 4. その他の対応環境
+
+### Android スマートフォン(Termux 経由)
+
+1GB 以上の RAM を搭載した ARM64 Android スマートフォン(2015年以降)。[Termux](https://github.com/termux/termux-app) をインストールし、`proot` を使用して PicoClaw を実行します。
+
+> セットアップ手順は [README:古い Android スマートフォンで実行](../../README.ja.md#-run-on-old-android-phones) を参照してください。
+
+### デスクトップ / サーバー / クラウド
+
+| プラットフォーム | 備考 |
+|------------------|------|
+| x86_64 Linux | ネイティブバイナリ、依存関係なし |
+| x86_64 Windows | ネイティブバイナリ |
+| macOS (Intel / Apple Silicon) | ネイティブバイナリ |
+| Docker (any platform) | `docker compose` ワンライナー、[Docker ガイド](docker.md) を参照 |
+| OpenWrt routers | MIPS/ARM ビルド、32MB 以上の空きメモリが必要 |
+| FreeBSD / NetBSD | x86_64 および arm64 ビルドが利用可能 |
+
+---
+
+## 5. 最小要件
+
+| リソース | 最小 | 推奨 |
+|----------|------|------|
+| RAM | 10MB 空き | 32MB 以上空き |
+| ストレージ | 20MB(バイナリ) | 50MB 以上(ワークスペース含む) |
+| CPU | 任意(シングルコア 0.6GHz 以上) | — |
+| OS | Linux (kernel 3.x+) | Linux 5.x+ |
+| ネットワーク | 必須(LLM API 呼び出し用) | イーサネットまたは WiFi |
+
+---
+
+## 6. テストと貢献の方法
+
+```bash
+# 1. お使いのアーキテクチャ向けをダウンロード
+wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
+tar xzf picoclaw_Linux_arm64.tar.gz
+
+# 2. 初期化
+./picoclaw onboard
+
+# 3. テスト
+./picoclaw agent -m "Hello, what board am I running on?"
+```
+
+利用可能なビルド:`linux-amd64`, `linux-arm64`, `linux-arm`, `linux-riscv64`, `linux-loong64`, `linux-mipsle`
+
+### ハードウェアを追加する
+
+1. このリポジトリをフォーク
+2. 該当するテーブルにチップ/製品/ボードを追加
+3. 名前、アーキテクチャ、SoC、RAM、年、リンク(あれば)を含める
+4. PR を送信
+
+ハードウェアベンダーの方へ:公式サポートの追加や共同プロモーションをご希望ですか?Issue を作成するか、[Discord](https://discord.gg/V4sAZ9XWpN) でお問い合わせください。
diff --git a/docs/ja/providers.md b/docs/ja/providers.md
index 2323a27cc..9a53a4b69 100644
--- a/docs/ja/providers.md
+++ b/docs/ja/providers.md
@@ -93,7 +93,7 @@
],
"agents": {
"defaults": {
- "model": "gpt-5.4"
+ "model_name": "gpt-5.4"
}
}
}
@@ -266,7 +266,7 @@ PicoClaw はリクエスト送信前に外側の `litellm/` プレフィック
],
"agents": {
"defaults": {
- "model": "glm-4.7"
+ "model_name": "glm-4.7"
}
}
}
@@ -298,7 +298,7 @@ PicoClaw はプロトコルファミリーごとに Provider をルーティン
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
+ "model_name": "glm-4.7",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
@@ -328,12 +328,11 @@ picoclaw agent -m "こんにちは"
{
"agents": {
"defaults": {
- "model": "anthropic/claude-opus-4-5"
+ "model_name": "anthropic/claude-opus-4-5"
}
},
"session": {
- "dm_scope": "per-channel-peer",
- "backlog_limit": 20
+ "dm_scope": "per-channel-peer"
},
"providers": {
"openrouter": {
diff --git a/docs/ja/troubleshooting.md b/docs/ja/troubleshooting.md
index 1c98224b9..f18b456db 100644
--- a/docs/ja/troubleshooting.md
+++ b/docs/ja/troubleshooting.md
@@ -16,7 +16,7 @@
**修正方法:** `~/.picoclaw/config.json`(またはお使いの設定パス)で:
-1. **agents.defaults.model** は `model_list` 内の `model_name` と一致する必要があります(例:`"openrouter-free"`)。
+1. **agents.defaults.model_name** は `model_list` 内の `model_name` と一致する必要があります(例:`"openrouter-free"`)。
2. そのエントリの **model** は有効な OpenRouter モデル ID である必要があります。例:
- `"openrouter/free"` – 自動無料枠
- `"google/gemini-2.0-flash-exp:free"`
@@ -28,7 +28,7 @@
{
"agents": {
"defaults": {
- "model": "openrouter-free"
+ "model_name": "openrouter-free"
}
},
"model_list": [
diff --git a/docs/migration/model-list-migration.md b/docs/migration/model-list-migration.md
index eed228d4d..9d05ac599 100644
--- a/docs/migration/model-list-migration.md
+++ b/docs/migration/model-list-migration.md
@@ -70,7 +70,7 @@ The new `model_list` configuration offers several advantages:
],
"agents": {
"defaults": {
- "model": "gpt4"
+ "model_name": "gpt4"
}
}
}
@@ -184,7 +184,7 @@ During the migration period, your existing `providers` configuration will contin
- [ ] Identify all providers you're currently using
- [ ] Create `model_list` entries for each provider
- [ ] Use appropriate protocol prefixes
-- [ ] Update `agents.defaults.model` to reference the new `model_name`
+- [ ] Update `agents.defaults.model_name` to reference the new `model_name`
- [ ] Test that all models work correctly
- [ ] Remove or comment out the old `providers` section
@@ -196,7 +196,7 @@ During the migration period, your existing `providers` configuration will contin
model "xxx" not found in model_list or providers
```
-**Solution**: Ensure the `model_name` in `model_list` matches the value in `agents.defaults.model`.
+**Solution**: Ensure the `model_name` in `model_list` matches the value in `agents.defaults.model_name`.
### Unknown protocol error
diff --git a/docs/providers.md b/docs/providers.md
index e62cbb969..dde1814fb 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -95,7 +95,7 @@ This design also enables **multi-agent support** with flexible provider selectio
],
"agents": {
"defaults": {
- "model": "gpt-5.4"
+ "model_name": "gpt-5.4"
}
}
}
@@ -268,13 +268,13 @@ The old `providers` configuration is **deprecated** but still supported for back
],
"agents": {
"defaults": {
- "model": "glm-4.7"
+ "model_name": "glm-4.7"
}
}
}
```
-For detailed migration guide, see [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
+For detailed migration guide, see [migration/model-list-migration.md](migration/model-list-migration.md).
### Provider Architecture
@@ -300,7 +300,7 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
+ "model_name": "glm-4.7",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
@@ -330,12 +330,11 @@ picoclaw agent -m "Hello"
{
"agents": {
"defaults": {
- "model": "anthropic/claude-opus-4-5"
+ "model_name": "anthropic/claude-opus-4-5"
}
},
"session": {
- "dm_scope": "per-channel-peer",
- "backlog_limit": 20
+ "dm_scope": "per-channel-peer"
},
"providers": {
"openrouter": {
diff --git a/docs/pt-br/ANTIGRAVITY_AUTH.md b/docs/pt-br/ANTIGRAVITY_AUTH.md
new file mode 100644
index 000000000..d243783cb
--- /dev/null
+++ b/docs/pt-br/ANTIGRAVITY_AUTH.md
@@ -0,0 +1,809 @@
+> Voltar ao [README](../../README.pt-br.md)
+
+# Guia de Autenticação e Integração do Antigravity
+
+## Visão Geral
+
+**Antigravity** (Google Cloud Code Assist) é um provedor de modelos de IA apoiado pelo Google que oferece acesso a modelos como Claude Opus 4.6 e Gemini através da infraestrutura de nuvem do Google. Este documento fornece um guia completo sobre como a autenticação funciona, como buscar modelos e como implementar um novo provedor no PicoClaw.
+
+---
+
+## Índice
+
+1. [Fluxo de Autenticação](#fluxo-de-autenticação)
+2. [Detalhes da Implementação OAuth](#detalhes-da-implementação-oauth)
+3. [Gerenciamento de Tokens](#gerenciamento-de-tokens)
+4. [Busca da Lista de Modelos](#busca-da-lista-de-modelos)
+5. [Rastreamento de Uso](#rastreamento-de-uso)
+6. [Estrutura do Plugin do Provedor](#estrutura-do-plugin-do-provedor)
+7. [Requisitos de Integração](#requisitos-de-integração)
+8. [Endpoints da API](#endpoints-da-api)
+9. [Configuração](#configuração)
+10. [Criando um Novo Provedor no PicoClaw](#criando-um-novo-provedor-no-picoclaw)
+
+---
+
+## Fluxo de Autenticação
+
+### 1. OAuth 2.0 com PKCE
+
+O Antigravity utiliza **OAuth 2.0 com PKCE (Proof Key for Code Exchange)** para autenticação segura:
+
+```
+┌─────────────┐ ┌─────────────────┐
+│ Client │ ───(1) Generate PKCE Pair────────> │ │
+│ │ ───(2) Open Auth URL─────────────> │ Google OAuth │
+│ │ │ Server │
+│ │ <──(3) Redirect with Code───────── │ │
+│ │ └─────────────────┘
+│ │ ───(4) Exchange Code for Tokens──> │ Token URL │
+│ │ │ │
+│ │ <──(5) Access + Refresh Tokens──── │ │
+└─────────────┘ └─────────────────┘
+```
+
+### 2. Etapas Detalhadas
+
+#### Etapa 1: Gerar Parâmetros PKCE
+```typescript
+function generatePkce(): { verifier: string; challenge: string } {
+ const verifier = randomBytes(32).toString("hex");
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
+ return { verifier, challenge };
+}
+```
+
+#### Etapa 2: Construir a URL de Autorização
+```typescript
+const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
+const REDIRECT_URI = "http://localhost:51121/oauth-callback";
+
+function buildAuthUrl(params: { challenge: string; state: string }): string {
+ const url = new URL(AUTH_URL);
+ url.searchParams.set("client_id", CLIENT_ID);
+ url.searchParams.set("response_type", "code");
+ url.searchParams.set("redirect_uri", REDIRECT_URI);
+ url.searchParams.set("scope", SCOPES.join(" "));
+ url.searchParams.set("code_challenge", params.challenge);
+ url.searchParams.set("code_challenge_method", "S256");
+ url.searchParams.set("state", params.state);
+ url.searchParams.set("access_type", "offline");
+ url.searchParams.set("prompt", "consent");
+ return url.toString();
+}
+```
+
+**Escopos Necessários:**
+```typescript
+const SCOPES = [
+ "https://www.googleapis.com/auth/cloud-platform",
+ "https://www.googleapis.com/auth/userinfo.email",
+ "https://www.googleapis.com/auth/userinfo.profile",
+ "https://www.googleapis.com/auth/cclog",
+ "https://www.googleapis.com/auth/experimentsandconfigs",
+];
+```
+
+#### Etapa 3: Tratar o Callback OAuth
+
+**Modo Automático (Desenvolvimento Local):**
+- Iniciar um servidor HTTP local na porta 51121
+- Aguardar o redirecionamento do Google
+- Extrair o código de autorização dos parâmetros da query
+
+**Modo Manual (Remoto/Sem Interface Gráfica):**
+- Exibir a URL de autorização para o usuário
+- O usuário completa a autenticação no navegador
+- O usuário cola a URL de redirecionamento completa no terminal
+- Analisar o código da URL colada
+
+#### Etapa 4: Trocar o Código por Tokens
+```typescript
+const TOKEN_URL = "https://oauth2.googleapis.com/token";
+
+async function exchangeCode(params: {
+ code: string;
+ verifier: string;
+}): Promise<{ access: string; refresh: string; expires: number }> {
+ const response = await fetch(TOKEN_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ client_id: CLIENT_ID,
+ client_secret: CLIENT_SECRET,
+ code: params.code,
+ grant_type: "authorization_code",
+ redirect_uri: REDIRECT_URI,
+ code_verifier: params.verifier,
+ }),
+ });
+
+ const data = await response.json();
+
+ return {
+ access: data.access_token,
+ refresh: data.refresh_token,
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer
+ };
+}
+```
+
+#### Etapa 5: Buscar Dados Adicionais do Usuário
+
+**E-mail do Usuário:**
+```typescript
+async function fetchUserEmail(accessToken: string): Promise {
+ const response = await fetch(
+ "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
+ { headers: { Authorization: `Bearer ${accessToken}` } }
+ );
+ const data = await response.json();
+ return data.email;
+}
+```
+
+**ID do Projeto (Necessário para chamadas de API):**
+```typescript
+async function fetchProjectId(accessToken: string): Promise {
+ const headers = {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "google-api-nodejs-client/9.15.1",
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+ "Client-Metadata": JSON.stringify({
+ ideType: "IDE_UNSPECIFIED",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ }),
+ };
+
+ const response = await fetch(
+ "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ metadata: {
+ ideType: "IDE_UNSPECIFIED",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ },
+ }),
+ }
+ );
+
+ const data = await response.json();
+ return data.cloudaicompanionProject || "rising-fact-p41fc"; // Valor padrão de fallback
+}
+```
+
+---
+
+## Detalhes da Implementação OAuth
+
+### Credenciais do Cliente
+
+**Importante:** Estas são codificadas em base64 no código-fonte para sincronização com pi-ai:
+
+```typescript
+const decode = (s: string) => Buffer.from(s, "base64").toString();
+
+const CLIENT_ID = decode(
+ "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ=="
+);
+const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
+```
+
+### Modos do Fluxo OAuth
+
+1. **Fluxo Automático** (máquinas locais com navegador):
+ - Abre o navegador automaticamente
+ - O servidor de callback local captura o redirecionamento
+ - Nenhuma interação do usuário necessária após a autenticação inicial
+
+2. **Fluxo Manual** (remoto/sem interface/WSL2):
+ - URL exibida para copiar e colar manualmente
+ - O usuário completa a autenticação em um navegador externo
+ - O usuário cola a URL de redirecionamento completa de volta
+
+```typescript
+function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
+ return isRemote || isWSL2Sync();
+}
+```
+
+---
+
+## Gerenciamento de Tokens
+
+### Estrutura do Perfil de Autenticação
+
+```typescript
+type OAuthCredential = {
+ type: "oauth";
+ provider: "google-antigravity";
+ access: string; // Token de acesso
+ refresh: string; // Token de atualização
+ expires: number; // Timestamp de expiração (ms desde epoch)
+ email?: string; // E-mail do usuário
+ projectId?: string; // ID do projeto Google Cloud
+};
+```
+
+### Atualização de Tokens
+
+A credencial inclui um token de atualização que pode ser usado para obter novos tokens de acesso quando o atual expira. A expiração é definida com um buffer de 5 minutos para evitar condições de corrida.
+
+---
+
+## Busca da Lista de Modelos
+
+### Buscar Modelos Disponíveis
+
+```typescript
+const BASE_URL = "https://cloudcode-pa.googleapis.com";
+
+async function fetchAvailableModels(
+ accessToken: string,
+ projectId: string
+): Promise {
+ const headers = {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "antigravity",
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+ };
+
+ const response = await fetch(
+ `${BASE_URL}/v1internal:fetchAvailableModels`,
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ project: projectId }),
+ }
+ );
+
+ const data = await response.json();
+
+ // Retorna modelos com informações de cota
+ return Object.entries(data.models).map(([modelId, modelInfo]) => ({
+ id: modelId,
+ displayName: modelInfo.displayName,
+ quotaInfo: {
+ remainingFraction: modelInfo.quotaInfo?.remainingFraction,
+ resetTime: modelInfo.quotaInfo?.resetTime,
+ isExhausted: modelInfo.quotaInfo?.isExhausted,
+ },
+ }));
+}
+```
+
+### Formato da Resposta
+
+```typescript
+type FetchAvailableModelsResponse = {
+ models?: Record;
+};
+```
+
+---
+
+## Rastreamento de Uso
+
+### Buscar Dados de Uso
+
+```typescript
+export async function fetchAntigravityUsage(
+ token: string,
+ timeoutMs: number
+): Promise {
+ // 1. Buscar créditos e informações do plano
+ const loadCodeAssistRes = await fetch(
+ `${BASE_URL}/v1internal:loadCodeAssist`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ metadata: {
+ ideType: "ANTIGRAVITY",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ },
+ }),
+ }
+ );
+
+ // Extrair informações de créditos
+ const { availablePromptCredits, planInfo, currentTier } = data;
+
+ // 2. Buscar cotas dos modelos
+ const modelsRes = await fetch(
+ `${BASE_URL}/v1internal:fetchAvailableModels`,
+ {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}` },
+ body: JSON.stringify({ project: projectId }),
+ }
+ );
+
+ // Construir janelas de uso
+ return {
+ provider: "google-antigravity",
+ displayName: "Google Antigravity",
+ windows: [
+ { label: "Credits", usedPercent: calculateUsedPercent(available, monthly) },
+ // Cotas individuais dos modelos...
+ ],
+ plan: currentTier?.name || planType,
+ };
+}
+```
+
+### Estrutura da Resposta de Uso
+
+```typescript
+type ProviderUsageSnapshot = {
+ provider: "google-antigravity";
+ displayName: string;
+ windows: UsageWindow[];
+ plan?: string;
+ error?: string;
+};
+
+type UsageWindow = {
+ label: string; // "Credits" ou ID do modelo
+ usedPercent: number; // 0-100
+ resetAt?: number; // Timestamp de quando a cota é redefinida
+};
+```
+
+---
+
+## Estrutura do Plugin do Provedor
+
+### Definição do Plugin
+
+```typescript
+const antigravityPlugin = {
+ id: "google-antigravity-auth",
+ name: "Google Antigravity Auth",
+ description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
+ configSchema: emptyPluginConfigSchema(),
+
+ register(api: PicoClawPluginApi) {
+ api.registerProvider({
+ id: "google-antigravity",
+ label: "Google Antigravity",
+ docsPath: "/providers/models",
+ aliases: ["antigravity"],
+
+ auth: [
+ {
+ id: "oauth",
+ label: "Google OAuth",
+ hint: "PKCE + localhost callback",
+ kind: "oauth",
+ run: async (ctx: ProviderAuthContext) => {
+ // Implementação OAuth aqui
+ },
+ },
+ ],
+ });
+ },
+};
+```
+
+### ProviderAuthContext
+
+```typescript
+type ProviderAuthContext = {
+ config: PicoClawConfig;
+ agentDir?: string;
+ workspaceDir?: string;
+ prompter: WizardPrompter; // Prompts/notificações da UI
+ runtime: RuntimeEnv; // Logging, etc.
+ isRemote: boolean; // Se está executando remotamente
+ openUrl: (url: string) => Promise; // Abridor de navegador
+ oauth: {
+ createVpsAwareHandlers: Function;
+ };
+};
+```
+
+### ProviderAuthResult
+
+```typescript
+type ProviderAuthResult = {
+ profiles: Array<{
+ profileId: string;
+ credential: AuthProfileCredential;
+ }>;
+ configPatch?: Partial;
+ defaultModel?: string;
+ notes?: string[];
+};
+```
+
+---
+
+## Requisitos de Integração
+
+### 1. Ambiente/Dependências Necessários
+
+- Go ≥ 1.25
+- Base de código do PicoClaw (`pkg/providers/` e `pkg/auth/`)
+- Pacotes da biblioteca padrão `crypto` e `net/http`
+
+### 2. Cabeçalhos Necessários para Chamadas de API
+
+```typescript
+const REQUIRED_HEADERS = {
+ "Authorization": `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "antigravity", // ou "google-api-nodejs-client/9.15.1"
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+};
+
+// Para chamadas loadCodeAssist, incluir também:
+const CLIENT_METADATA = {
+ ideType: "ANTIGRAVITY", // ou "IDE_UNSPECIFIED"
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+};
+```
+
+### 3. Sanitização de Schemas de Modelos
+
+O Antigravity usa modelos compatíveis com Gemini, então os schemas de ferramentas devem ser sanitizados:
+
+```typescript
+const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
+ "patternProperties",
+ "additionalProperties",
+ "$schema",
+ "$id",
+ "$ref",
+ "$defs",
+ "definitions",
+ "examples",
+ "minLength",
+ "maxLength",
+ "minimum",
+ "maximum",
+ "multipleOf",
+ "pattern",
+ "format",
+ "minItems",
+ "maxItems",
+ "uniqueItems",
+ "minProperties",
+ "maxProperties",
+]);
+
+// Limpar schema antes de enviar
+function cleanToolSchemaForGemini(schema: Record): unknown {
+ // Remover palavras-chave não suportadas
+ // Garantir que o nível superior tenha type: "object"
+ // Achatar uniões anyOf/oneOf
+}
+```
+
+### 4. Tratamento de Blocos de Pensamento (Modelos Claude)
+
+Para modelos Claude via Antigravity, os blocos de pensamento requerem tratamento especial:
+
+```typescript
+const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/;
+
+export function sanitizeAntigravityThinkingBlocks(
+ messages: AgentMessage[]
+): AgentMessage[] {
+ // Validar assinaturas de pensamento
+ // Normalizar campos de assinatura
+ // Descartar blocos de pensamento não assinados
+}
+```
+
+---
+
+## Endpoints da API
+
+### Endpoints de Autenticação
+
+| Endpoint | Método | Finalidade |
+|----------|--------|-----------|
+| `https://accounts.google.com/o/oauth2/v2/auth` | GET | Autorização OAuth |
+| `https://oauth2.googleapis.com/token` | POST | Troca de tokens |
+| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | Informações do usuário (e-mail) |
+
+### Endpoints do Cloud Code Assist
+
+| Endpoint | Método | Finalidade |
+|----------|--------|-----------|
+| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | Carregar informações do projeto, créditos, plano |
+| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | Listar modelos disponíveis com cotas |
+| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | Endpoint de streaming de chat |
+
+**Formato de Requisição da API (Chat):**
+O endpoint `v1internal:streamGenerateContent` espera um envelope encapsulando a requisição Gemini padrão:
+
+```json
+{
+ "project": "your-project-id",
+ "model": "model-id",
+ "request": {
+ "contents": [...],
+ "systemInstruction": {...},
+ "generationConfig": {...},
+ "tools": [...]
+ },
+ "requestType": "agent",
+ "userAgent": "antigravity",
+ "requestId": "agent-timestamp-random"
+}
+```
+
+**Formato de Resposta da API (SSE):**
+Cada mensagem SSE (`data: {...}`) é encapsulada em um campo `response`:
+
+```json
+{
+ "response": {
+ "candidates": [...],
+ "usageMetadata": {...},
+ "modelVersion": "...",
+ "responseId": "..."
+ },
+ "traceId": "...",
+ "metadata": {}
+}
+```
+
+---
+
+## Configuração
+
+### Configuração do config.json
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gemini-flash",
+ "model": "antigravity/gemini-3-flash",
+ "auth_method": "oauth"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model_name": "gemini-flash"
+ }
+ }
+}
+```
+
+### Armazenamento do Perfil de Autenticação
+
+Os perfis de autenticação são armazenados em `~/.picoclaw/auth.json`:
+
+```json
+{
+ "credentials": {
+ "google-antigravity": {
+ "access_token": "ya29...",
+ "refresh_token": "1//...",
+ "expires_at": "2026-01-01T00:00:00Z",
+ "provider": "google-antigravity",
+ "auth_method": "oauth",
+ "email": "user@example.com",
+ "project_id": "my-project-id"
+ }
+ }
+}
+```
+
+---
+
+## Criando um Novo Provedor no PicoClaw
+
+Os provedores do PicoClaw são implementados como pacotes Go em `pkg/providers/`. Para adicionar um novo provedor:
+
+### Implementação Passo a Passo
+
+#### 1. Criar o Arquivo do Provedor
+
+Crie um novo arquivo Go em `pkg/providers/`:
+
+```
+pkg/providers/
+└── your_provider.go
+```
+
+#### 2. Implementar a Interface Provider
+
+Seu provedor deve implementar a interface `Provider` definida em `pkg/providers/types.go`:
+
+```go
+package providers
+
+type YourProvider struct {
+ apiKey string
+ apiBase string
+}
+
+func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider {
+ if apiBase == "" {
+ apiBase = "https://api.your-provider.com/v1"
+ }
+ return &YourProvider{apiKey: apiKey, apiBase: apiBase}
+}
+
+func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error {
+ // Implementar conclusão de chat com streaming
+}
+```
+
+#### 3. Registrar na Factory
+
+Adicione seu provedor ao switch de protocolo em `pkg/providers/factory.go`:
+
+```go
+case "your-provider":
+ return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil
+```
+
+#### 4. Adicionar Configuração Padrão (Opcional)
+
+Adicione uma entrada padrão em `pkg/config/defaults.go`:
+
+```go
+{
+ ModelName: "your-model",
+ Model: "your-provider/model-name",
+ APIKey: "",
+},
+```
+
+#### 5. Adicionar Suporte de Autenticação (Opcional)
+
+Se seu provedor requer OAuth ou autenticação especial, adicione um caso em `cmd/picoclaw/internal/auth/helpers.go`:
+
+```go
+case "your-provider":
+ authLoginYourProvider()
+```
+
+#### 6. Configurar via `config.json`
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "your-model",
+ "model": "your-provider/model-name",
+ "api_key": "your-api-key",
+ "api_base": "https://api.your-provider.com/v1"
+ }
+ ]
+}
+```
+
+---
+
+## Testando Sua Implementação
+
+### Comandos CLI
+
+```bash
+# Autenticar com um provedor
+picoclaw auth login --provider your-provider
+
+# Listar modelos (para Antigravity)
+picoclaw auth models
+
+# Iniciar o gateway
+picoclaw gateway
+
+# Executar um agente com um modelo específico
+picoclaw agent -m "Hello" --model your-model
+```
+
+### Variáveis de Ambiente para Testes
+
+```bash
+# Substituir o modelo padrão
+export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model
+
+# Substituir configurações do provedor
+export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]'
+```
+
+---
+
+## Referências
+
+- **Arquivos Fonte:**
+ - `pkg/providers/antigravity_provider.go` - Implementação do provedor Antigravity
+ - `pkg/auth/oauth.go` - Implementação do fluxo OAuth
+ - `pkg/auth/store.go` - Armazenamento de credenciais de autenticação (`~/.picoclaw/auth.json`)
+ - `pkg/providers/factory.go` - Factory de provedores e roteamento de protocolo
+ - `pkg/providers/types.go` - Definições da interface do provedor
+ - `cmd/picoclaw/internal/auth/helpers.go` - Comandos CLI de autenticação
+
+- **Documentação:**
+ - `docs/ANTIGRAVITY_USAGE.md` - Guia de uso do Antigravity
+ - `docs/migration/model-list-migration.md` - Guia de migração
+
+---
+
+## Observações
+
+1. **Projeto Google Cloud:** O Antigravity requer que o Gemini for Google Cloud esteja habilitado no seu projeto Google Cloud
+2. **Cotas:** Usa cotas do projeto Google Cloud (sem cobrança separada)
+3. **Acesso a Modelos:** Os modelos disponíveis dependem da configuração do seu projeto Google Cloud
+4. **Blocos de Pensamento:** Modelos Claude via Antigravity requerem tratamento especial de blocos de pensamento com assinaturas
+5. **Sanitização de Schemas:** Os schemas de ferramentas devem ser sanitizados para remover palavras-chave JSON Schema não suportadas
+
+---
+
+---
+
+## Tratamento de Erros Comuns
+
+### 1. Limitação de Taxa (HTTP 429)
+
+O Antigravity retorna um erro 429 quando as cotas do projeto/modelo estão esgotadas. A resposta de erro frequentemente contém um `quotaResetDelay` no campo `details`.
+
+**Exemplo de Erro 429:**
+```json
+{
+ "error": {
+ "code": 429,
+ "message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.",
+ "status": "RESOURCE_EXHAUSTED",
+ "details": [
+ {
+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
+ "metadata": {
+ "quotaResetDelay": "4h30m28.060903746s"
+ }
+ }
+ ]
+ }
+}
+```
+
+### 2. Respostas Vazias (Modelos Restritos)
+
+Alguns modelos podem aparecer na lista de modelos disponíveis, mas retornar uma resposta vazia (200 OK mas stream SSE vazio). Isso geralmente acontece com modelos em preview ou restritos que o projeto atual não tem permissão para usar.
+
+**Tratamento:** Tratar respostas vazias como erros informando ao usuário que o modelo pode estar restrito ou inválido para seu projeto.
+
+---
+
+## Solução de Problemas
+
+### "Token expired" (token expirado)
+- Atualizar tokens OAuth: `picoclaw auth login --provider antigravity`
+
+### "Gemini for Google Cloud is not enabled" (Gemini for Google Cloud não está habilitado)
+- Habilitar a API no seu Google Cloud Console
+
+### "Project not found" (projeto não encontrado)
+- Verificar se seu projeto Google Cloud tem as APIs necessárias habilitadas
+- Verificar se o ID do projeto foi obtido corretamente durante a autenticação
+
+### Modelos não aparecem na lista
+- Verificar se a autenticação OAuth foi concluída com sucesso
+- Verificar o armazenamento do perfil de autenticação: `~/.picoclaw/auth.json`
+- Executar novamente `picoclaw auth login --provider antigravity`
diff --git a/docs/pt-br/ANTIGRAVITY_USAGE.md b/docs/pt-br/ANTIGRAVITY_USAGE.md
new file mode 100644
index 000000000..d4b681ad0
--- /dev/null
+++ b/docs/pt-br/ANTIGRAVITY_USAGE.md
@@ -0,0 +1,72 @@
+> Voltar ao [README](../../README.pt-br.md)
+
+# Usando o provedor Antigravity no PicoClaw
+
+Este guia explica como configurar e usar o provedor **Antigravity** (Google Cloud Code Assist) no PicoClaw.
+
+## Pré-requisitos
+
+1. Uma conta Google.
+2. Google Cloud Code Assist habilitado (geralmente disponível através da integração "Gemini for Google Cloud").
+
+## 1. Autenticação
+
+Para se autenticar com o Antigravity, execute o seguinte comando:
+
+```bash
+picoclaw auth login --provider antigravity
+```
+
+### Autenticação manual (Headless/VPS)
+Se você está executando em um servidor (Coolify/Docker) e não consegue acessar `localhost`, siga estas etapas:
+1. Execute o comando acima.
+2. Copie a URL fornecida e abra-a no seu navegador local.
+3. Complete o login.
+4. Seu navegador será redirecionado para uma URL `localhost:51121` (que não carregará).
+5. **Copie essa URL final** da barra de endereços do seu navegador.
+6. **Cole-a de volta no terminal** onde o PicoClaw está aguardando.
+
+O PicoClaw extrairá automaticamente o código de autorização e completará o processo.
+
+## 2. Gerenciando modelos
+
+### Listar modelos disponíveis
+Para ver quais modelos seu projeto tem acesso e verificar suas cotas:
+
+```bash
+picoclaw auth models
+```
+
+### Trocar de modelo
+Você pode alterar o modelo padrão em `~/.picoclaw/config.json` ou substituí-lo via CLI:
+
+```bash
+# Substituir para um único comando
+picoclaw agent -m "Hello" --model claude-opus-4-6-thinking
+```
+
+## 3. Uso em produção (Coolify/Docker)
+
+Se você está implantando via Coolify ou Docker, siga estas etapas para testar:
+
+1. **Variáveis de ambiente**:
+ * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash`
+2. **Persistência da autenticação**:
+ Se você já fez login localmente, pode copiar suas credenciais para o servidor:
+ ```bash
+ scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/
+ ```
+ *Alternativamente*, execute o comando `auth login` uma vez no servidor se você tiver acesso ao terminal.
+
+## 4. Solução de problemas
+
+* **Resposta vazia**: Se um modelo retorna uma resposta vazia, ele pode estar restrito para o seu projeto. Tente `gemini-3-flash` ou `claude-opus-4-6-thinking`.
+* **429 Limite de taxa**: O Antigravity possui cotas rigorosas. O PicoClaw exibirá o "tempo de redefinição" na mensagem de erro se você atingir um limite.
+* **404 Não encontrado**: Certifique-se de que está usando um ID de modelo da lista `picoclaw auth models`. Use o ID curto (ex.: `gemini-3-flash`) e não o caminho completo.
+
+## 5. Resumo dos modelos funcionais
+
+Com base nos testes, os seguintes modelos são os mais confiáveis:
+* `gemini-3-flash` (Rápido, alta disponibilidade)
+* `gemini-2.5-flash-lite` (Leve)
+* `claude-opus-4-6-thinking` (Poderoso, inclui raciocínio)
diff --git a/docs/pt-br/chat-apps.md b/docs/pt-br/chat-apps.md
index 5f18080f0..08ef292fa 100644
--- a/docs/pt-br/chat-apps.md
+++ b/docs/pt-br/chat-apps.md
@@ -8,22 +8,22 @@ Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, D
> **Nota**: Todos os canais baseados em webhook (LINE, WeCom, etc.) são servidos em um único servidor HTTP Gateway compartilhado (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). Não há portas por canal para configurar. Nota: Feishu usa o modo WebSocket/SDK e não utiliza o servidor HTTP webhook compartilhado.
-| Channel | Setup |
-| ------------ | ---------------------------------- |
-| **Telegram** | Easy (just a token) |
-| **Discord** | Easy (bot token + intents) |
-| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
-| **Matrix** | Medium (homeserver + bot access token) |
-| **QQ** | Easy (AppID + AppSecret) |
-| **DingTalk** | Medium (app credentials) |
-| **LINE** | Medium (credentials + webhook URL) |
-| **WeCom AI Bot** | Medium (Token + AES key) |
-| **Feishu** | Medium (App ID + Secret, WebSocket mode) |
-| **Slack** | Medium (Bot token + App token) |
-| **IRC** | Medium (server + TLS config) |
-| **OneBot** | Medium (QQ via OneBot protocol) |
-| **MaixCam** | Easy (Sipeed hardware integration) |
-| **Pico** | Native PicoClaw protocol |
+| Canal | Dificuldade | Descrição | Documentação |
+| -------------------- | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
+| **Telegram** | ⭐ Fácil | Recomendado, voz para texto, long polling (sem IP público) | [Documentação](../channels/telegram/README.pt-br.md) |
+| **Discord** | ⭐ Fácil | Socket Mode, suporte a grupos/DM, ecossistema bot rico | [Documentação](../channels/discord/README.pt-br.md) |
+| **WhatsApp** | ⭐ Fácil | Nativo (scan QR) ou Bridge URL | [Documentação](#whatsapp) |
+| **Slack** | ⭐ Fácil | **Socket Mode** (sem IP público), empresarial | [Documentação](../channels/slack/README.pt-br.md) |
+| **Matrix** | ⭐⭐ Médio | Protocolo federado, suporte a auto-hospedagem | [Documentação](../channels/matrix/README.pt-br.md) |
+| **QQ** | ⭐⭐ Médio | API bot oficial, comunidade chinesa | [Documentação](../channels/qq/README.pt-br.md) |
+| **DingTalk** | ⭐⭐ Médio | Modo Stream (sem IP público), empresarial | [Documentação](../channels/dingtalk/README.pt-br.md) |
+| **LINE** | ⭐⭐⭐ Avançado | HTTPS Webhook obrigatório | [Documentação](../channels/line/README.pt-br.md) |
+| **WeCom (企业微信)** | ⭐⭐⭐ Avançado | Bot de grupo (Webhook), app personalizado (API), AI Bot | [Bot](../channels/wecom/wecom_bot/README.pt-br.md) / [App](../channels/wecom/wecom_app/README.pt-br.md) / [AI Bot](../channels/wecom/wecom_aibot/README.pt-br.md) |
+| **Feishu (飞书)** | ⭐⭐⭐ Avançado | Colaboração empresarial, rico em recursos | [Documentação](../channels/feishu/README.pt-br.md) |
+| **IRC** | ⭐⭐ Médio | Servidor + configuração TLS | - |
+| **OneBot** | ⭐⭐ Médio | Compatível com NapCat/Go-CQHTTP, ecossistema comunitário | [Documentação](../channels/onebot/README.pt-br.md) |
+| **MaixCam** | ⭐ Fácil | Canal de integração de hardware para câmeras AI Sipeed | [Documentação](../channels/maixcam/README.pt-br.md) |
+| **Pico** | ⭐ Fácil | Canal de protocolo nativo PicoClaw | |
Telegram (Recomendado)
@@ -168,12 +168,13 @@ Se `session_store_path` estiver vazio, a sessão é armazenada em `/w
QQ
-**1. Criar um bot**
+**Configuração rápida (recomendada)**
-- Acesse a [QQ Open Platform](https://q.qq.com/#)
-- Crie um aplicativo → Obtenha **AppID** e **AppSecret**
+A QQ Open Platform oferece uma página de configuração com um clique para bots compatíveis com OpenClaw:
-**2. Configurar**
+1. Abra o [QQ Bot Quick Start](https://q.qq.com/qqbot/openclaw/index.html) e escaneie o QR code para fazer login
+2. Um bot é criado automaticamente — copie o **App ID** e o **App Secret**
+3. Configure o PicoClaw:
```json
{
@@ -188,13 +189,20 @@ Se `session_store_path` estiver vazio, a sessão é armazenada em `/w
}
```
-> Defina `allow_from` como vazio para permitir todos os usuários, ou especifique números QQ para restringir o acesso.
+4. Execute `picoclaw gateway` e abra o QQ para conversar com seu bot
-**3. Executar**
+> O App Secret é exibido apenas uma vez. Salve-o imediatamente — visualizá-lo novamente forçará uma redefinição.
+>
+> Bots criados pela página de configuração rápida são inicialmente apenas para o criador e não suportam chats de grupo. Para habilitar o acesso em grupo, configure o modo sandbox na [QQ Open Platform](https://q.qq.com/).
-```bash
-picoclaw gateway
-```
+**Configuração manual**
+
+Se preferir criar o bot manualmente:
+
+* Faça login na [QQ Open Platform](https://q.qq.com/) para se registrar como desenvolvedor
+* Crie um bot QQ — personalize seu avatar e nome
+* Copie o **App ID** e o **App Secret** nas configurações do bot
+* Configure conforme mostrado acima e execute `picoclaw gateway`
@@ -229,8 +237,31 @@ picoclaw gateway
```bash
picoclaw gateway
```
+
+
+MaixCam
+
+Canal de integração projetado especificamente para hardware de câmera AI Sipeed.
+
+```json
+{
+ "channels": {
+ "maixcam": {
+ "enabled": true
+ }
+ }
+}
+```
+
+```bash
+picoclaw gateway
+```
+
+
+
+
Matrix
@@ -261,7 +292,7 @@ picoclaw gateway
picoclaw gateway
```
-Para opções completas (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), veja o [Guia de Configuração do Canal Matrix](docs/channels/matrix/README.md).
+Para opções completas (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), veja o [Guia de Configuração do Canal Matrix](../channels/matrix/README.md).
@@ -322,7 +353,7 @@ O PicoClaw suporta três tipos de integração WeCom:
**Opção 2: WeCom App (App Personalizado)** - Mais recursos, mensagens proativas, apenas chat privado
**Opção 3: WeCom AI Bot (AI Bot)** - AI Bot oficial, respostas em streaming, suporta chat de grupo e privado
-Veja o [Guia de Configuração do WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) para instruções detalhadas de configuração.
+Veja o [Guia de Configuração do WeCom AI Bot](../channels/wecom/wecom_aibot/README.pt-br.md) para instruções detalhadas de configuração.
**Configuração Rápida - WeCom Bot:**
@@ -396,7 +427,7 @@ picoclaw gateway
**1. Criar um AI Bot**
* Acesse o Console de Administração WeCom → Gerenciamento de Apps → AI Bot
-* Nas configurações do AI Bot, configure a URL de callback: `http://your-server:18791/webhook/wecom-aibot`
+* Nas configurações do AI Bot, configure a URL de callback: `http://your-server:18790/webhook/wecom-aibot`
* Copie o **Token** e clique em "Gerar Aleatoriamente" para o **EncodingAESKey**
**2. Configurar**
@@ -425,3 +456,169 @@ picoclaw gateway
> **Nota**: O WeCom AI Bot usa protocolo de streaming pull — sem preocupações com timeout de resposta. Tarefas longas (>30 segundos) mudam automaticamente para entrega via `response_url` push.
+
+
+Feishu (Lark)
+
+O PicoClaw se conecta ao Feishu via modo WebSocket/SDK — não é necessário URL de webhook público nem servidor de callback.
+
+**1. Criar um aplicativo**
+
+* Acesse a [Feishu Open Platform](https://open.feishu.cn/) e crie um aplicativo
+* Nas configurações do aplicativo, habilite a capacidade **Bot**
+* Crie uma versão e publique o aplicativo (o aplicativo deve ser publicado para funcionar)
+* Copie o **App ID** (começa com `cli_`) e o **App Secret**
+
+**2. Configurar**
+
+```json
+{
+ "channels": {
+ "feishu": {
+ "enabled": true,
+ "app_id": "cli_xxx",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+Opcional: `encrypt_key` e `verification_token` para criptografia de eventos (recomendado para produção).
+
+**3. Executar e conversar**
+
+```bash
+picoclaw gateway
+```
+
+Abra o Feishu, pesquise o nome do seu bot e comece a conversar. Você também pode adicionar o bot a um grupo — use `group_trigger.mention_only: true` para responder apenas quando @mencionado.
+
+Para opções completas, veja o [Guia de Configuração do Canal Feishu](../channels/feishu/README.pt-br.md).
+
+
+
+
+Slack
+
+**1. Criar um aplicativo Slack**
+
+* Acesse a [Slack API](https://api.slack.com/apps) e crie um novo aplicativo
+* Em **OAuth & Permissions**, adicione os escopos do bot: `chat:write`, `app_mentions:read`, `im:history`, `im:read`, `im:write`
+* Instale o aplicativo no seu workspace
+* Copie o **Bot Token** (`xoxb-...`) e o **App-Level Token** (`xapp-...`, habilite Socket Mode para obtê-lo)
+
+**2. Configurar**
+
+```json
+{
+ "channels": {
+ "slack": {
+ "enabled": true,
+ "bot_token": "xoxb-YOUR-BOT-TOKEN",
+ "app_token": "xapp-YOUR-APP-TOKEN",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**3. Executar**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+IRC
+
+**1. Configurar**
+
+```json
+{
+ "channels": {
+ "irc": {
+ "enabled": true,
+ "server": "irc.libera.chat:6697",
+ "tls": true,
+ "nick": "picoclaw-bot",
+ "channels": ["#your-channel"],
+ "password": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+Opcional: `nickserv_password` para autenticação NickServ, `sasl_user`/`sasl_password` para autenticação SASL.
+
+**2. Executar**
+
+```bash
+picoclaw gateway
+```
+
+O bot se conectará ao servidor IRC e entrará nos canais especificados.
+
+
+
+
+OneBot (QQ via protocolo OneBot)
+
+OneBot é um protocolo aberto para bots QQ. O PicoClaw se conecta a qualquer implementação compatível com OneBot v11 (ex.: [Lagrange](https://github.com/LagrangeDev/Lagrange.Core), [NapCat](https://github.com/NapNeko/NapCatQQ)) via WebSocket.
+
+**1. Configurar uma implementação OneBot**
+
+Instale e execute um framework de bot QQ compatível com OneBot v11. Habilite seu servidor WebSocket.
+
+**2. Configurar**
+
+```json
+{
+ "channels": {
+ "onebot": {
+ "enabled": true,
+ "ws_url": "ws://127.0.0.1:8080",
+ "access_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Campo | Descrição |
+|-------|-----------|
+| `ws_url` | URL WebSocket da implementação OneBot |
+| `access_token` | Token de acesso para autenticação (se configurado no OneBot) |
+| `reconnect_interval` | Intervalo de reconexão em segundos (padrão: 5) |
+
+**3. Executar**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+MaixCam
+
+Canal de integração projetado especificamente para hardware de câmera AI Sipeed.
+
+```json
+{
+ "channels": {
+ "maixcam": {
+ "enabled": true
+ }
+ }
+}
+```
+
+```bash
+picoclaw gateway
+```
+
+
diff --git a/docs/pt-br/configuration.md b/docs/pt-br/configuration.md
index e7e2c7ec0..ee14ca724 100644
--- a/docs/pt-br/configuration.md
+++ b/docs/pt-br/configuration.md
@@ -57,7 +57,7 @@ Por padrão, as skills são carregadas de:
1. `~/.picoclaw/workspace/skills` (workspace)
2. `~/.picoclaw/skills` (global)
-3. `/skills` (builtin)
+3. `/skills` (embutido)
Para configurações avançadas/de teste, você pode substituir o diretório raiz de skills builtin com:
diff --git a/docs/pt-br/credential_encryption.md b/docs/pt-br/credential_encryption.md
new file mode 100644
index 000000000..59a31e438
--- /dev/null
+++ b/docs/pt-br/credential_encryption.md
@@ -0,0 +1,159 @@
+> Voltar ao [README](../../README.pt-br.md)
+
+# Criptografia de Credenciais
+
+O PicoClaw suporta a criptografia de valores `api_key` nas entradas de configuração `model_list`.
+As chaves criptografadas são armazenadas como strings `enc://` e descriptografadas automaticamente na inicialização.
+
+---
+
+## Início Rápido
+
+**1. Defina sua frase secreta**
+
+```bash
+export PICOCLAW_KEY_PASSPHRASE="your-passphrase"
+```
+
+**2. Criptografe uma chave de API**
+
+Execute `picoclaw onboard` — ele solicita sua frase secreta e gera a chave SSH,
+depois recriptografa automaticamente quaisquer entradas `api_key` em texto simples na sua configuração
+na próxima chamada `SaveConfig`. O valor `enc://` resultante será semelhante a:
+
+```
+enc://AAAA...base64...
+```
+
+**3. Cole a saída na sua configuração**
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-4o",
+ "model": "openai/gpt-4o",
+ "api_key": "enc://AAAA...base64...",
+ "api_base": "https://api.openai.com/v1"
+ }
+ ]
+}
+```
+
+---
+
+## Formatos de `api_key` Suportados
+
+| Formato | Exemplo | Comportamento |
+|---------|---------|---------------|
+| Texto simples | `sk-abc123` | Usado como está |
+| Referência de arquivo | `file://openai.key` | Conteúdo lido do mesmo diretório do arquivo de configuração |
+| Criptografado | `enc://` | Descriptografado na inicialização usando `PICOCLAW_KEY_PASSPHRASE` |
+| Vazio | `""` | Passado sem alteração (usado com `auth_method: oauth`) |
+
+---
+
+## Design Criptográfico
+
+### Derivação de Chave
+
+A criptografia utiliza **HKDF-SHA256** com uma chave privada SSH como segundo fator.
+
+```
+sshHash = SHA256(ssh_private_key_file_bytes)
+ikm = HMAC-SHA256(key=sshHash, message=passphrase)
+aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes)
+```
+
+### Criptografia
+
+```
+AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key)
+```
+
+### Formato de Transmissão
+
+```
+enc://
+```
+
+| Campo | Tamanho | Descrição |
+|-------|---------|-----------|
+| `salt` | 16 bytes | Aleatório por criptografia; alimentado no HKDF |
+| `nonce` | 12 bytes | Aleatório por criptografia; IV do AES-GCM |
+| `ciphertext` | variável | Texto cifrado AES-256-GCM + tag de autenticação de 16 bytes |
+
+O tag de autenticação GCM é anexado automaticamente ao texto cifrado. Qualquer adulteração faz com que a descriptografia falhe com um erro em vez de retornar texto simples corrompido.
+
+### Desempenho
+
+| Operação | Tempo (ARM Cortex-A) |
+|----------|----------------------|
+| Derivação de chave (HKDF) | < 1 ms |
+| Descriptografia AES-256-GCM | < 1 ms |
+| **Sobrecarga total na inicialização** | **< 2 ms por chave** |
+
+---
+
+## Segurança de Dois Fatores com Chave SSH
+
+Quando uma chave privada SSH é fornecida, quebrar a criptografia requer **ambos**:
+
+1. A **frase secreta** (`PICOCLAW_KEY_PASSPHRASE`)
+2. O **arquivo de chave privada SSH**
+
+Isso significa que um arquivo de configuração vazado sozinho não é suficiente para recuperar a chave de API, mesmo que a frase secreta seja fraca. A chave SSH contribui com 256 bits de entropia (Ed25519) independentemente da força da frase secreta.
+
+### Modelo de Ameaça
+
+| O que o atacante possui | Pode descriptografar? |
+|------------------------|----------------------|
+| Apenas o arquivo de configuração | Não — necessita da frase secreta + chave SSH |
+| Apenas a chave SSH | Não — necessita da frase secreta |
+| Apenas a frase secreta | Não — necessita da chave SSH |
+| Arquivo de configuração + chave SSH + frase secreta | Sim — comprometimento total |
+
+---
+
+## Variáveis de Ambiente
+
+| Variável | Obrigatório | Descrição |
+|----------|-------------|-----------|
+| `PICOCLAW_KEY_PASSPHRASE` | Sim (para `enc://`) | Frase secreta usada para derivação de chave |
+| `PICOCLAW_SSH_KEY_PATH` | Não | Caminho para a chave privada SSH. Se não definido, detecta automaticamente em `~/.ssh/picoclaw_ed25519.key` |
+
+### Detecção Automática da Chave SSH
+
+Se `PICOCLAW_SSH_KEY_PATH` não estiver definido, o PicoClaw procura a chave dedicada:
+
+```
+~/.ssh/picoclaw_ed25519.key
+```
+
+Este arquivo dedicado evita conflitos com as chaves SSH existentes do usuário.
+Execute `picoclaw onboard` para gerá-lo automaticamente.
+
+`os.UserHomeDir()` é usado para resolução multiplataforma do diretório home (lê `USERPROFILE` no Windows, `HOME` no Unix/macOS).
+
+> **Nota:** Um arquivo de chave SSH é obrigatório para a criptografia de credenciais. Se nenhuma chave for encontrada e `PICOCLAW_SSH_KEY_PATH` não estiver definido, a criptografia/descriptografia falhará. Execute `picoclaw onboard` para gerar a chave automaticamente.
+
+---
+
+## Migração
+
+Como os únicos materiais secretos são `PICOCLAW_KEY_PASSPHRASE` e o arquivo de chave privada SSH, a migração é simples:
+
+1. Copie o arquivo de configuração para a nova máquina.
+2. Defina `PICOCLAW_KEY_PASSPHRASE` com o mesmo valor.
+3. Copie o arquivo de chave privada SSH para o mesmo caminho (ou defina `PICOCLAW_SSH_KEY_PATH` para sua nova localização).
+
+Nenhuma recriptografia é necessária.
+
+---
+
+## Considerações de Segurança
+
+- **Tanto a frase secreta quanto a chave SSH são obrigatórias.** A chave SSH atua como um segundo fator — sem ela, a criptografia/descriptografia falhará. Execute `picoclaw onboard` para gerar a chave se ela não existir.
+- **A chave SSH é somente leitura em tempo de execução.** O PicoClaw nunca escreve ou modifica o arquivo de chave SSH.
+- **Chaves em texto simples continuam sendo suportadas.** Configurações existentes sem `enc://` não são afetadas.
+- **O formato `enc://` é versionado** através do campo `info` do HKDF (`picoclaw-credential-v1`), permitindo futuras atualizações de algoritmo sem quebrar valores criptografados existentes.
diff --git a/docs/pt-br/debug.md b/docs/pt-br/debug.md
new file mode 100644
index 000000000..8614cd5ed
--- /dev/null
+++ b/docs/pt-br/debug.md
@@ -0,0 +1,36 @@
+# Depuração do PicoClaw
+
+> Voltar ao [README](../../README.pt-br.md)
+
+O PicoClaw realiza múltiplas interações complexas nos bastidores para cada requisição que recebe — desde o roteamento de mensagens e avaliação de complexidade, até a execução de ferramentas e adaptação a falhas de modelo. Poder ver exatamente o que está acontecendo é crucial, não apenas para solucionar problemas potenciais, mas também para realmente entender como o agente opera.
+
+## Iniciando o PicoClaw em modo de depuração
+
+Para obter informações detalhadas sobre o que o agente está fazendo (requisições LLM, chamadas de ferramentas, roteamento de mensagens), você pode iniciar o gateway do PicoClaw com a flag de depuração:
+
+```bash
+picoclaw gateway --debug
+# or
+picoclaw gateway -d
+```
+
+Neste modo, o sistema formata os logs de forma detalhada e exibe prévias dos prompts do sistema e dos resultados de execução das ferramentas.
+
+## Desabilitando a truncagem de logs (logs completos)
+
+Por padrão, o PicoClaw trunca strings muito longas (como o *Prompt do Sistema* ou resultados JSON grandes) nos logs de depuração para manter o console legível.
+
+Se você precisar inspecionar a saída completa de um comando ou o payload exato enviado ao modelo LLM, pode usar a flag `--no-truncate`.
+
+**Nota:** Esta flag *só* funciona quando combinada com o modo `--debug`.
+
+```bash
+picoclaw gateway --debug --no-truncate
+
+```
+
+Quando esta flag está ativa, a função de truncagem global é desabilitada. Isso é extremamente útil para:
+
+* Verificar a sintaxe exata das mensagens enviadas ao provedor.
+* Ler a saída completa de ferramentas como `exec`, `web_fetch` ou `read_file`.
+* Depurar o histórico de sessão salvo na memória.
diff --git a/docs/pt-br/docker.md b/docs/pt-br/docker.md
index af58c89b2..bac48954b 100644
--- a/docs/pt-br/docker.md
+++ b/docs/pt-br/docker.md
@@ -12,6 +12,7 @@ git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
# 2. Primeira execução — gera automaticamente docker/data/config.json e encerra
+# (só é acionado quando config.json e workspace/ estão ambos ausentes)
docker compose -f docker/docker-compose.yml --profile gateway up
# O contêiner exibe "First-run setup complete." e para.
diff --git a/docs/pt-br/hardware-compatibility.md b/docs/pt-br/hardware-compatibility.md
new file mode 100644
index 000000000..771621014
--- /dev/null
+++ b/docs/pt-br/hardware-compatibility.md
@@ -0,0 +1,152 @@
+> Voltar ao [README](../../README.pt-br.md)
+
+# 🖥️ PicoClaw Lista de compatibilidade de hardware
+
+O PicoClaw roda em praticamente qualquer dispositivo Linux. Esta página registra chips, produtos e placas de desenvolvimento verificados.
+
+**Seu hardware não está na lista?** Envie um PR para adicioná-lo! Fabricantes de hardware são bem-vindos para contribuir e co-promover.
+
+---
+
+## 1. Suporte a chips verificado
+
+### x86
+
+| Fabricante | Chip | Notas |
+|------------|------|-------|
+| Intel | Any x86 CPU (i386+) | Todos os processadores desktop/servidor/notebook |
+| AMD | Any x86 CPU | Todos os processadores desktop/servidor/notebook |
+
+### ARM
+
+| Sub-arq | Chips típicos | Notas |
+|---------|---------------|-------|
+| ARMv6 | [BCM2835](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2835) (Raspberry Pi 1/Zero) | Single-core ARM1176JZF-S |
+| ARMv7 | [Allwinner V3s](https://linux-sunxi.org/V3s) | Single-core Cortex-A7, usado no LicheePi Zero |
+| ARM64 | [Allwinner H618](https://linux-sunxi.org/H618) | Quad-core Cortex-A53, usado no Orange Pi Zero 3 |
+| ARM64 | [BCM2711](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2711) (Raspberry Pi 4) | Quad-core Cortex-A72 |
+| ARM64 | [BCM2712](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2712) (Raspberry Pi 5) | Quad-core Cortex-A76 |
+| ARM64 | [AX630C](https://www.axera-tech.com/) (爱芯元智) | Dual-core Cortex-A53 + NPU, usado no NanoKVM-Pro / MaixCAM2 |
+
+### RISC-V (riscv64)
+
+| Fabricante | Chip | Núcleo | Notas |
+|------------|------|--------|-------|
+| [SOPHGO (算能)](https://www.sophgo.com/) | SG2002 | C906 @ 1GHz | 256MB DDR3 integrado, usado no LicheeRV-Nano / NanoKVM / MaixCAM |
+| [Allwinner (全志)](https://www.allwinnertech.com/) | V861 | Dual C907 | 128MB DDR3L integrado, 1 TOPS NPU, câmera AI 4K SiP |
+| [Allwinner (全志)](https://www.allwinnertech.com/) | V881 | C907 | Série de câmeras AI RISC-V |
+| [Arterytek (匠芯创)](https://www.arterytek.com/) | D213 | RISC-V | Usado no HaaS506-LD1 RTU industrial |
+| [SpacemiT (进迭)](https://www.spacemit.com/) | K1 | 8x X60 @ 1.8GHz | Usado no Milk-V Jupiter, BananaPi BPI-F3 |
+| [SpacemiT (进迭)](https://www.spacemit.com/) | K3 | 8x X100 @ 2.5GHz | Compatível com RVA23, RVV de 1024 bits, inferência AI FP8 |
+| [Zhihe (知合)](https://www.zhihe-tech.com/) | A210 | High-perf RISC-V | 8 núcleos, 16MB cache L3, classe desktop |
+| [Canaan (嘉楠)](https://www.canaan-creative.com/) | K230 | Dual C908 @ 1.6GHz | 6 TOPS KPU, usado no CanMV-K230 |
+
+### MIPS
+
+| Fabricante | Chip | Notas |
+|------------|------|-------|
+| MediaTek | [MT7620](https://www.mediatek.com/products/home-networking/mt7620) | MIPS24KEc @ 580MHz, usado em muitos roteadores OpenWrt (ex. Xiaomi Router 3G) |
+
+### LoongArch (loong64)
+
+| Fabricante | Chip | Notas |
+|------------|------|-------|
+| [Loongson (龙芯)](https://www.loongson.cn/) | 3A5000 | Quad-core LA464 @ 2.5GHz, desktop/estação de trabalho |
+| [Loongson (龙芯)](https://www.loongson.cn/) | 3A6000 | Quad-core 4C/8T @ 2.5GHz, IPC comparável ao Intel 10ª geração |
+| [Loongson (龙芯)](https://www.loongson.cn/) | 2K1000LA | Dual-core @ 1GHz, aplicações industriais/IoT |
+
+---
+
+## 2. Produtos verificados (por data de lançamento)
+
+Produtos de consumo, roteadores e dispositivos industriais testados com o PicoClaw.
+
+| Ano | Produto | Arq | SoC | RAM | Categoria |
+|-----|---------|-----|-----|-----|-----------|
+| 2009 | Nokia N900 | ARM (A8) | OMAP3430 | 256MB | Smartphone |
+| 2012 | Samsung Galaxy Note 10.1 (N8000) | ARM (A9) | Exynos 4412 | 2GB | Tablet |
+| 2016 | Xiaomi Router 3G (小米路由器3G) | MIPS | MT7620 | 256MB | Roteador (OpenWrt) |
+| 2018 | Phicomm N1 (斐讯N1) | ARM64 (A53) | S905D | 2GB | TV Box / Servidor doméstico |
+| 2019 | Xiaomi AI Speaker (小爱音箱) | ARM64 (A53) | — | 256MB | Alto-falante inteligente |
+| 2024 | [NanoKVM](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html) | RISC-V | SG2002 | 256MB | IP-KVM |
+| 2025 | HaaS506-LD1 | RISC-V | D213 | 128MB | RTU industrial |
+| 2025 | [NanoKVM-Pro](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM_Pro/introduction.html) | ARM64 (A53) | AX630C | 1GB | IP-KVM Pro |
+| 2026 | [MaixCAM2](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | ARM64 (A53) | AX630C | 1/4GB | Câmera AI 4K |
+
+---
+
+## 3. Placas de desenvolvimento verificadas (por data de lançamento)
+
+| Ano | Placa | Arq | SoC | RAM | Link de compra |
+|-----|-------|-----|-----|-----|----------------|
+| 2012 | [Raspberry Pi 1 Model B](https://www.raspberrypi.com/products/) | ARMv6 | BCM2835 | 512MB | — |
+| 2015 | [Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) | ARMv7 (A7) | BCM2836 | 1GB | — |
+| 2015 | [Raspberry Pi Zero](https://www.raspberrypi.com/products/raspberry-pi-zero/) | ARMv6 | BCM2835 | 512MB | — |
+| 2016 | [Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) | ARM64 (A53) | BCM2837 | 1GB | — |
+| 2017 | [LicheePi Zero](https://wiki.sipeed.com/hardware/en/lichee/Zero/Zero.html) | ARMv7 (A7) | Allwinner V3s | 64MB | [Sipeed](https://sipeed.com/) |
+| 2019 | [Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) | ARM64 (A72) | BCM2711 | 1~8GB | [RPi](https://www.raspberrypi.com/) |
+| 2023 | [Raspberry Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/) | ARM64 (A76) | BCM2712 | 2~8GB | [RPi](https://www.raspberrypi.com/) |
+| 2024 | [LicheeRV-Nano](https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html) | RISC-V | SG2002 | 256MB | [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) |
+| 2024 | [MaixCAM-Pro](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | RISC-V | SG2002 | 256MB | [Sipeed](https://sipeed.com/) |
+| 2024 | [Milk-V Duo 64M](https://milkv.io/docs/duo/getting-started/duo) | RISC-V | CV1800B | 64MB | [Milk-V](https://milkv.io/) |
+| 2024 | [CanMV-K230](https://developer.canaan-creative.com/k230_canmv/en/main/) | RISC-V | K230 | 512MB | [Canaan](https://www.canaan-creative.com/) |
+
+---
+
+## 4. Também funciona em
+
+### Celulares Android (via Termux)
+
+Qualquer celular Android ARM64 (2015+) com 1GB+ de RAM. Instale o [Termux](https://github.com/termux/termux-app), use `proot` para rodar o PicoClaw.
+
+> Veja [README: Rodar em celulares Android antigos](../../README.pt-br.md#-run-on-old-android-phones) para instruções de configuração.
+
+### Desktop / Servidor / Nuvem
+
+| Plataforma | Notas |
+|------------|-------|
+| x86_64 Linux | Binário nativo, sem dependências |
+| x86_64 Windows | Binário nativo |
+| macOS (Intel / Apple Silicon) | Binário nativo |
+| Docker (any platform) | `docker compose` em uma linha, veja [Guia Docker](docker.md) |
+| OpenWrt routers | Builds MIPS/ARM, requer >32MB de RAM livre |
+| FreeBSD / NetBSD | Builds x86_64 e arm64 disponíveis |
+
+---
+
+## 5. Requisitos mínimos
+
+| Recurso | Mínimo | Recomendado |
+|---------|--------|-------------|
+| RAM | 10MB livres | 32MB+ livres |
+| Armazenamento | 20MB (binário) | 50MB+ (com workspace) |
+| CPU | Qualquer (single-core 0,6GHz+) | — |
+| OS | Linux (kernel 3.x+) | Linux 5.x+ |
+| Rede | Necessária (para chamadas de API LLM) | Ethernet ou WiFi |
+
+---
+
+## 6. Como testar e contribuir
+
+```bash
+# 1. Baixar para sua arquitetura
+wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
+tar xzf picoclaw_Linux_arm64.tar.gz
+
+# 2. Inicializar
+./picoclaw onboard
+
+# 3. Testar
+./picoclaw agent -m "Hello, what board am I running on?"
+```
+
+Builds disponíveis: `linux-amd64`, `linux-arm64`, `linux-arm`, `linux-riscv64`, `linux-loong64`, `linux-mipsle`
+
+### Adicionar seu hardware
+
+1. Faça fork deste repositório
+2. Adicione seu chip / produto / placa na tabela apropriada
+3. Inclua: nome, arquitetura, SoC, RAM, ano e um link se disponível
+4. Envie um PR
+
+Fabricantes de hardware: deseja adicionar suporte oficial ou co-promover? Abra uma issue ou entre em contato via [Discord](https://discord.gg/V4sAZ9XWpN).
diff --git a/docs/pt-br/providers.md b/docs/pt-br/providers.md
index 04fb9fc6b..0f7a4b5a1 100644
--- a/docs/pt-br/providers.md
+++ b/docs/pt-br/providers.md
@@ -93,7 +93,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de
],
"agents": {
"defaults": {
- "model": "gpt-5.4"
+ "model_name": "gpt-5.4"
}
}
}
@@ -266,13 +266,13 @@ A configuração antiga `providers` está **descontinuada** mas ainda é suporta
],
"agents": {
"defaults": {
- "model": "glm-4.7"
+ "model_name": "glm-4.7"
}
}
}
```
-Para guia de migração detalhado, veja [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
+Para guia de migração detalhado, veja [migration/model-list-migration.md](../migration/model-list-migration.md).
### Arquitetura de Provedores
@@ -298,7 +298,7 @@ Isso mantém o runtime leve enquanto torna novos backends compatíveis com OpenA
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
+ "model_name": "glm-4.7",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
@@ -328,12 +328,11 @@ picoclaw agent -m "Hello"
{
"agents": {
"defaults": {
- "model": "anthropic/claude-opus-4-5"
+ "model_name": "anthropic/claude-opus-4-5"
}
},
"session": {
- "dm_scope": "per-channel-peer",
- "backlog_limit": 20
+ "dm_scope": "per-channel-peer"
},
"providers": {
"openrouter": {
diff --git a/docs/pt-br/troubleshooting.md b/docs/pt-br/troubleshooting.md
index e6c1a55ab..286ad2ac8 100644
--- a/docs/pt-br/troubleshooting.md
+++ b/docs/pt-br/troubleshooting.md
@@ -16,7 +16,7 @@
**Correção:** Em `~/.picoclaw/config.json` (ou seu caminho de configuração):
-1. **agents.defaults.model** deve corresponder a um `model_name` em `model_list` (ex.: `"openrouter-free"`).
+1. **agents.defaults.model_name** deve corresponder a um `model_name` em `model_list` (ex.: `"openrouter-free"`).
2. O **model** dessa entrada deve ser um ID de modelo OpenRouter válido, por exemplo:
- `"openrouter/free"` – nível gratuito automático
- `"google/gemini-2.0-flash-exp:free"`
@@ -28,7 +28,7 @@ Exemplo:
{
"agents": {
"defaults": {
- "model": "openrouter-free"
+ "model_name": "openrouter-free"
}
},
"model_list": [
diff --git a/docs/spawn-tasks.md b/docs/spawn-tasks.md
index eff96ce45..05a5215d2 100644
--- a/docs/spawn-tasks.md
+++ b/docs/spawn-tasks.md
@@ -2,6 +2,15 @@
> Back to [README](../README.md)
+PicoClaw supports **asynchronous task execution** via the `spawn` tool. This is primarily used by the **Heartbeat** system to run long-running tasks without blocking the main agent loop.
+
+## Heartbeat
+
+The heartbeat system periodically checks `workspace/HEARTBEAT.md` for scheduled tasks. On first run, a default template is auto-generated. You can customize it to define quick tasks (handled inline) and long tasks (delegated via `spawn`).
+
+**Example `HEARTBEAT.md`:**
+
+```markdown
## Quick Tasks (respond directly)
- Report current time
diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md
index 2e0a22d3b..d0160050d 100644
--- a/docs/tools_configuration.md
+++ b/docs/tools_configuration.md
@@ -41,11 +41,12 @@ General settings for fetching and processing webpage content.
### Brave
-| Config | Type | Default | Description |
-|---------------|--------|---------|---------------------------|
-| `enabled` | bool | false | Enable Brave search |
-| `api_key` | string | - | Brave Search API key |
-| `max_results` | int | 5 | Maximum number of results |
+| Config | Type | Default | Description |
+|---------------|----------|---------|------------------------------------------------|
+| `enabled` | bool | false | Enable Brave search |
+| `api_key` | string | - | Brave Search API key |
+| `api_keys` | string[] | - | Multiple API keys for rotation (takes priority over `api_key`) |
+| `max_results` | int | 5 | Maximum number of results |
### DuckDuckGo
@@ -56,11 +57,46 @@ General settings for fetching and processing webpage content.
### Perplexity
+| Config | Type | Default | Description |
+|---------------|----------|---------|------------------------------------------------|
+| `enabled` | bool | false | Enable Perplexity search |
+| `api_key` | string | - | Perplexity API key |
+| `api_keys` | string[] | - | Multiple API keys for rotation (takes priority over `api_key`) |
+| `max_results` | int | 5 | Maximum number of results |
+
+### Tavily
+
| Config | Type | Default | Description |
|---------------|--------|---------|---------------------------|
-| `enabled` | bool | false | Enable Perplexity search |
-| `api_key` | string | - | Perplexity API key |
-| `max_results` | int | 5 | Maximum number of results |
+| `enabled` | bool | false | Enable Tavily search |
+| `api_key` | string | - | Tavily API key |
+| `base_url` | string | - | Custom Tavily API base URL |
+| `max_results` | int | 0 | Maximum number of results (0 = default) |
+
+### SearXNG
+
+| Config | Type | Default | Description |
+|---------------|--------|--------------------------|---------------------------|
+| `enabled` | bool | false | Enable SearXNG search |
+| `base_url` | string | `http://localhost:8888` | SearXNG instance URL |
+| `max_results` | int | 5 | Maximum number of results |
+
+### GLM Search
+
+| Config | Type | Default | Description |
+|-----------------|--------|------------------------------------------------------|---------------------------|
+| `enabled` | bool | false | Enable GLM Search |
+| `api_key` | string | - | GLM API key |
+| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | GLM Search API URL |
+| `search_engine` | string | `search_std` | Search engine type |
+| `max_results` | int | 5 | Maximum number of results |
+
+### Additional Web Settings
+
+| Config | Type | Default | Description |
+|--------------------------|----------|---------|----------------------------------------------------------------|
+| `prefer_native` | bool | true | Prefer provider's native search over configured search engines |
+| `private_host_whitelist` | string[] | `[]` | Private/internal hosts allowed for web fetching |
## Exec Tool
@@ -155,6 +191,7 @@ The cron tool is used for scheduling periodic tasks.
| Config | Type | Default | Description |
|------------------------|------|---------|------------------------------------------------|
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
+| `allow_command` | bool | false | Allow cron tasks to execute shell commands |
## MCP Tool
@@ -370,9 +407,27 @@ The skills tool configures skill discovery and installation via registries like
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
| `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits |
-| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
-| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
-| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
+| `registries.clawhub.search_path` | string | `""` | Search API path |
+| `registries.clawhub.skills_path` | string | `""` | Skills API path |
+| `registries.clawhub.download_path` | string | `""` | Download API path |
+| `registries.clawhub.timeout` | int | 0 | Request timeout in seconds (0 = default) |
+| `registries.clawhub.max_zip_size` | int | 0 | Max skill zip size in bytes (0 = default) |
+| `registries.clawhub.max_response_size` | int | 0 | Max API response size in bytes (0 = default) |
+
+### GitHub Integration
+
+| Config | Type | Default | Description |
+|------------------|--------|---------|--------------------------------------|
+| `github.proxy` | string | `""` | HTTP proxy for GitHub API requests |
+| `github.token` | string | `""` | GitHub personal access token |
+
+### Search Settings
+
+| Config | Type | Default | Description |
+|---------------------------|------|---------|--------------------------------------------|
+| `max_concurrent_searches` | int | 2 | Max concurrent skill search requests |
+| `search_cache.max_size` | int | 50 | Max cached search results |
+| `search_cache.ttl_seconds`| int | 300 | Cache TTL in seconds |
### Configuration Example
@@ -384,11 +439,17 @@ The skills tool configures skill discovery and installation via registries like
"clawhub": {
"enabled": true,
"base_url": "https://clawhub.ai",
- "auth_token": "",
- "search_path": "/api/v1/search",
- "skills_path": "/api/v1/skills",
- "download_path": "/api/v1/download"
+ "auth_token": ""
}
+ },
+ "github": {
+ "proxy": "",
+ "token": ""
+ },
+ "max_concurrent_searches": 2,
+ "search_cache": {
+ "max_size": 50,
+ "ttl_seconds": 300
}
}
}
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 219d2c6e3..096beec78 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -14,7 +14,7 @@
**Fix:** In `~/.picoclaw/config.json` (or your config path):
-1. **agents.defaults.model** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`).
+1. **agents.defaults.model_name** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`).
2. That entry’s **model** must be a valid OpenRouter model ID, for example:
- `"openrouter/free"` – auto free-tier
- `"google/gemini-2.0-flash-exp:free"`
@@ -26,7 +26,7 @@ Example snippet:
{
"agents": {
"defaults": {
- "model": "openrouter-free"
+ "model_name": "openrouter-free"
}
},
"model_list": [
diff --git a/docs/vi/ANTIGRAVITY_AUTH.md b/docs/vi/ANTIGRAVITY_AUTH.md
new file mode 100644
index 000000000..783dc5181
--- /dev/null
+++ b/docs/vi/ANTIGRAVITY_AUTH.md
@@ -0,0 +1,807 @@
+> Quay lại [README](../../README.vi.md)
+
+# Hướng dẫn Xác thực và Tích hợp Antigravity
+
+## Tổng quan
+
+**Antigravity** (Google Cloud Code Assist) là nhà cung cấp mô hình AI được Google hỗ trợ, cung cấp quyền truy cập vào các mô hình như Claude Opus 4.6 và Gemini thông qua hạ tầng đám mây của Google. Tài liệu này cung cấp hướng dẫn đầy đủ về cách xác thực hoạt động, cách lấy danh sách mô hình và cách triển khai nhà cung cấp mới trong PicoClaw.
+
+---
+
+## Mục lục
+
+1. [Luồng xác thực](#luồng-xác-thực)
+2. [Chi tiết triển khai OAuth](#chi-tiết-triển-khai-oauth)
+3. [Quản lý token](#quản-lý-token)
+4. [Lấy danh sách mô hình](#lấy-danh-sách-mô-hình)
+5. [Theo dõi mức sử dụng](#theo-dõi-mức-sử-dụng)
+6. [Cấu trúc plugin nhà cung cấp](#cấu-trúc-plugin-nhà-cung-cấp)
+7. [Yêu cầu tích hợp](#yêu-cầu-tích-hợp)
+8. [Các endpoint API](#các-endpoint-api)
+9. [Cấu hình](#cấu-hình)
+10. [Tạo nhà cung cấp mới trong PicoClaw](#tạo-nhà-cung-cấp-mới-trong-picoclaw)
+
+---
+
+## Luồng xác thực
+
+### 1. OAuth 2.0 với PKCE
+
+Antigravity sử dụng **OAuth 2.0 với PKCE (Proof Key for Code Exchange)** để xác thực an toàn:
+
+```
+┌─────────────┐ ┌─────────────────┐
+│ Client │ ───(1) Generate PKCE Pair────────> │ │
+│ │ ───(2) Open Auth URL─────────────> │ Google OAuth │
+│ │ │ Server │
+│ │ <──(3) Redirect with Code───────── │ │
+│ │ └─────────────────┘
+│ │ ───(4) Exchange Code for Tokens──> │ Token URL │
+│ │ │ │
+│ │ <──(5) Access + Refresh Tokens──── │ │
+└─────────────┘ └─────────────────┘
+```
+
+### 2. Các bước chi tiết
+
+#### Bước 1: Tạo tham số PKCE
+```typescript
+function generatePkce(): { verifier: string; challenge: string } {
+ const verifier = randomBytes(32).toString("hex");
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
+ return { verifier, challenge };
+}
+```
+
+#### Bước 2: Xây dựng URL ủy quyền
+```typescript
+const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
+const REDIRECT_URI = "http://localhost:51121/oauth-callback";
+
+function buildAuthUrl(params: { challenge: string; state: string }): string {
+ const url = new URL(AUTH_URL);
+ url.searchParams.set("client_id", CLIENT_ID);
+ url.searchParams.set("response_type", "code");
+ url.searchParams.set("redirect_uri", REDIRECT_URI);
+ url.searchParams.set("scope", SCOPES.join(" "));
+ url.searchParams.set("code_challenge", params.challenge);
+ url.searchParams.set("code_challenge_method", "S256");
+ url.searchParams.set("state", params.state);
+ url.searchParams.set("access_type", "offline");
+ url.searchParams.set("prompt", "consent");
+ return url.toString();
+}
+```
+
+**Các phạm vi quyền cần thiết:**
+```typescript
+const SCOPES = [
+ "https://www.googleapis.com/auth/cloud-platform",
+ "https://www.googleapis.com/auth/userinfo.email",
+ "https://www.googleapis.com/auth/userinfo.profile",
+ "https://www.googleapis.com/auth/cclog",
+ "https://www.googleapis.com/auth/experimentsandconfigs",
+];
+```
+
+#### Bước 3: Xử lý callback OAuth
+
+**Chế độ tự động (Phát triển cục bộ):**
+- Khởi động máy chủ HTTP cục bộ trên cổng 51121
+- Chờ chuyển hướng từ Google
+- Trích xuất mã ủy quyền từ tham số truy vấn
+
+**Chế độ thủ công (Từ xa/Không có giao diện):**
+- Hiển thị URL ủy quyền cho người dùng
+- Người dùng hoàn tất xác thực trong trình duyệt
+- Người dùng dán URL chuyển hướng đầy đủ vào terminal
+- Phân tích mã từ URL đã dán
+
+#### Bước 4: Đổi mã lấy token
+```typescript
+const TOKEN_URL = "https://oauth2.googleapis.com/token";
+
+async function exchangeCode(params: {
+ code: string;
+ verifier: string;
+}): Promise<{ access: string; refresh: string; expires: number }> {
+ const response = await fetch(TOKEN_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ client_id: CLIENT_ID,
+ client_secret: CLIENT_SECRET,
+ code: params.code,
+ grant_type: "authorization_code",
+ redirect_uri: REDIRECT_URI,
+ code_verifier: params.verifier,
+ }),
+ });
+
+ const data = await response.json();
+
+ return {
+ access: data.access_token,
+ refresh: data.refresh_token,
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer
+ };
+}
+```
+
+#### Bước 5: Lấy dữ liệu người dùng bổ sung
+
+**Email người dùng:**
+```typescript
+async function fetchUserEmail(accessToken: string): Promise {
+ const response = await fetch(
+ "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
+ { headers: { Authorization: `Bearer ${accessToken}` } }
+ );
+ const data = await response.json();
+ return data.email;
+}
+```
+
+**ID dự án (Bắt buộc cho các lệnh gọi API):**
+```typescript
+async function fetchProjectId(accessToken: string): Promise {
+ const headers = {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "google-api-nodejs-client/9.15.1",
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+ "Client-Metadata": JSON.stringify({
+ ideType: "IDE_UNSPECIFIED",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ }),
+ };
+
+ const response = await fetch(
+ "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ metadata: {
+ ideType: "IDE_UNSPECIFIED",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ },
+ }),
+ }
+ );
+
+ const data = await response.json();
+ return data.cloudaicompanionProject || "rising-fact-p41fc"; // Giá trị mặc định dự phòng
+}
+```
+
+---
+
+## Chi tiết triển khai OAuth
+
+### Thông tin xác thực client
+
+**Quan trọng:** Các giá trị này được mã hóa base64 trong mã nguồn để đồng bộ với pi-ai:
+
+```typescript
+const decode = (s: string) => Buffer.from(s, "base64").toString();
+
+const CLIENT_ID = decode(
+ "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ=="
+);
+const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
+```
+
+### Các chế độ luồng OAuth
+
+1. **Luồng tự động** (Máy cục bộ có trình duyệt):
+ - Tự động mở trình duyệt
+ - Máy chủ callback cục bộ bắt chuyển hướng
+ - Không cần tương tác người dùng sau xác thực ban đầu
+
+2. **Luồng thủ công** (Từ xa/Không có giao diện/WSL2):
+ - Hiển thị URL để sao chép-dán thủ công
+ - Người dùng hoàn tất xác thực trong trình duyệt bên ngoài
+ - Người dùng dán lại URL chuyển hướng đầy đủ
+
+```typescript
+function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
+ return isRemote || isWSL2Sync();
+}
+```
+
+---
+
+## Quản lý token
+
+### Cấu trúc hồ sơ xác thực
+
+```typescript
+type OAuthCredential = {
+ type: "oauth";
+ provider: "google-antigravity";
+ access: string; // Token truy cập
+ refresh: string; // Token làm mới
+ expires: number; // Dấu thời gian hết hạn (ms kể từ epoch)
+ email?: string; // Email người dùng
+ projectId?: string; // ID dự án Google Cloud
+};
+```
+
+### Làm mới token
+
+Thông tin xác thực bao gồm token làm mới có thể được sử dụng để lấy token truy cập mới khi token hiện tại hết hạn. Thời gian hết hạn được đặt với bộ đệm 5 phút để tránh điều kiện tranh chấp.
+
+---
+
+## Lấy danh sách mô hình
+
+### Lấy các mô hình khả dụng
+
+```typescript
+const BASE_URL = "https://cloudcode-pa.googleapis.com";
+
+async function fetchAvailableModels(
+ accessToken: string,
+ projectId: string
+): Promise {
+ const headers = {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "antigravity",
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+ };
+
+ const response = await fetch(
+ `${BASE_URL}/v1internal:fetchAvailableModels`,
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ project: projectId }),
+ }
+ );
+
+ const data = await response.json();
+
+ // Trả về các mô hình kèm thông tin hạn mức
+ return Object.entries(data.models).map(([modelId, modelInfo]) => ({
+ id: modelId,
+ displayName: modelInfo.displayName,
+ quotaInfo: {
+ remainingFraction: modelInfo.quotaInfo?.remainingFraction,
+ resetTime: modelInfo.quotaInfo?.resetTime,
+ isExhausted: modelInfo.quotaInfo?.isExhausted,
+ },
+ }));
+}
+```
+
+### Định dạng phản hồi
+
+```typescript
+type FetchAvailableModelsResponse = {
+ models?: Record;
+};
+```
+
+---
+
+## Theo dõi mức sử dụng
+
+### Lấy dữ liệu sử dụng
+
+```typescript
+export async function fetchAntigravityUsage(
+ token: string,
+ timeoutMs: number
+): Promise {
+ // 1. Lấy thông tin tín dụng và gói dịch vụ
+ const loadCodeAssistRes = await fetch(
+ `${BASE_URL}/v1internal:loadCodeAssist`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ metadata: {
+ ideType: "ANTIGRAVITY",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ },
+ }),
+ }
+ );
+
+ // Trích xuất thông tin tín dụng
+ const { availablePromptCredits, planInfo, currentTier } = data;
+
+ // 2. Lấy hạn mức mô hình
+ const modelsRes = await fetch(
+ `${BASE_URL}/v1internal:fetchAvailableModels`,
+ {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}` },
+ body: JSON.stringify({ project: projectId }),
+ }
+ );
+
+ // Xây dựng cửa sổ sử dụng
+ return {
+ provider: "google-antigravity",
+ displayName: "Google Antigravity",
+ windows: [
+ { label: "Credits", usedPercent: calculateUsedPercent(available, monthly) },
+ // Hạn mức từng mô hình...
+ ],
+ plan: currentTier?.name || planType,
+ };
+}
+```
+
+### Cấu trúc phản hồi sử dụng
+
+```typescript
+type ProviderUsageSnapshot = {
+ provider: "google-antigravity";
+ displayName: string;
+ windows: UsageWindow[];
+ plan?: string;
+ error?: string;
+};
+
+type UsageWindow = {
+ label: string; // "Credits" hoặc ID mô hình
+ usedPercent: number; // 0-100
+ resetAt?: number; // Dấu thời gian khi hạn mức được đặt lại
+};
+```
+
+---
+
+## Cấu trúc plugin nhà cung cấp
+
+### Định nghĩa plugin
+
+```typescript
+const antigravityPlugin = {
+ id: "google-antigravity-auth",
+ name: "Google Antigravity Auth",
+ description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
+ configSchema: emptyPluginConfigSchema(),
+
+ register(api: PicoClawPluginApi) {
+ api.registerProvider({
+ id: "google-antigravity",
+ label: "Google Antigravity",
+ docsPath: "/providers/models",
+ aliases: ["antigravity"],
+
+ auth: [
+ {
+ id: "oauth",
+ label: "Google OAuth",
+ hint: "PKCE + localhost callback",
+ kind: "oauth",
+ run: async (ctx: ProviderAuthContext) => {
+ // Triển khai OAuth tại đây
+ },
+ },
+ ],
+ });
+ },
+};
+```
+
+### ProviderAuthContext
+
+```typescript
+type ProviderAuthContext = {
+ config: PicoClawConfig;
+ agentDir?: string;
+ workspaceDir?: string;
+ prompter: WizardPrompter; // Lời nhắc/thông báo UI
+ runtime: RuntimeEnv; // Ghi log, v.v.
+ isRemote: boolean; // Có đang chạy từ xa không
+ openUrl: (url: string) => Promise; // Mở trình duyệt
+ oauth: {
+ createVpsAwareHandlers: Function;
+ };
+};
+```
+
+### ProviderAuthResult
+
+```typescript
+type ProviderAuthResult = {
+ profiles: Array<{
+ profileId: string;
+ credential: AuthProfileCredential;
+ }>;
+ configPatch?: Partial;
+ defaultModel?: string;
+ notes?: string[];
+};
+```
+
+---
+
+## Yêu cầu tích hợp
+
+### 1. Môi trường/Phụ thuộc cần thiết
+
+- Go ≥ 1.25
+- Mã nguồn PicoClaw (`pkg/providers/` và `pkg/auth/`)
+- Các gói thư viện chuẩn `crypto` và `net/http`
+
+### 2. Các header bắt buộc cho lệnh gọi API
+
+```typescript
+const REQUIRED_HEADERS = {
+ "Authorization": `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "antigravity", // hoặc "google-api-nodejs-client/9.15.1"
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+};
+
+// Đối với các lệnh gọi loadCodeAssist, cũng bao gồm:
+const CLIENT_METADATA = {
+ ideType: "ANTIGRAVITY", // hoặc "IDE_UNSPECIFIED"
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+};
+```
+
+### 3. Làm sạch schema mô hình
+
+Antigravity sử dụng các mô hình tương thích Gemini, vì vậy schema công cụ phải được làm sạch:
+
+```typescript
+const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
+ "patternProperties",
+ "additionalProperties",
+ "$schema",
+ "$id",
+ "$ref",
+ "$defs",
+ "definitions",
+ "examples",
+ "minLength",
+ "maxLength",
+ "minimum",
+ "maximum",
+ "multipleOf",
+ "pattern",
+ "format",
+ "minItems",
+ "maxItems",
+ "uniqueItems",
+ "minProperties",
+ "maxProperties",
+]);
+
+// Làm sạch schema trước khi gửi
+function cleanToolSchemaForGemini(schema: Record): unknown {
+ // Xóa các từ khóa không được hỗ trợ
+ // Đảm bảo cấp cao nhất có type: "object"
+ // Làm phẳng các union anyOf/oneOf
+}
+```
+
+### 4. Xử lý khối suy nghĩ (Mô hình Claude)
+
+Đối với các mô hình Claude qua Antigravity, khối suy nghĩ cần xử lý đặc biệt:
+
+```typescript
+const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/;
+
+export function sanitizeAntigravityThinkingBlocks(
+ messages: AgentMessage[]
+): AgentMessage[] {
+ // Xác thực chữ ký suy nghĩ
+ // Chuẩn hóa các trường chữ ký
+ // Loại bỏ các khối suy nghĩ chưa ký
+}
+```
+
+---
+
+## Các endpoint API
+
+### Endpoint xác thực
+
+| Endpoint | Phương thức | Mục đích |
+|----------|------------|----------|
+| `https://accounts.google.com/o/oauth2/v2/auth` | GET | Ủy quyền OAuth |
+| `https://oauth2.googleapis.com/token` | POST | Trao đổi token |
+| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | Thông tin người dùng (email) |
+
+### Endpoint Cloud Code Assist
+
+| Endpoint | Phương thức | Mục đích |
+|----------|------------|----------|
+| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | Tải thông tin dự án, tín dụng, gói dịch vụ |
+| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | Liệt kê các mô hình khả dụng kèm hạn mức |
+| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | Endpoint streaming chat |
+
+**Định dạng yêu cầu API (Chat):**
+Endpoint `v1internal:streamGenerateContent` yêu cầu một envelope bao bọc yêu cầu Gemini tiêu chuẩn:
+
+```json
+{
+ "project": "your-project-id",
+ "model": "model-id",
+ "request": {
+ "contents": [...],
+ "systemInstruction": {...},
+ "generationConfig": {...},
+ "tools": [...]
+ },
+ "requestType": "agent",
+ "userAgent": "antigravity",
+ "requestId": "agent-timestamp-random"
+}
+```
+
+**Định dạng phản hồi API (SSE):**
+Mỗi thông điệp SSE (`data: {...}`) được bao bọc trong trường `response`:
+
+```json
+{
+ "response": {
+ "candidates": [...],
+ "usageMetadata": {...},
+ "modelVersion": "...",
+ "responseId": "..."
+ },
+ "traceId": "...",
+ "metadata": {}
+}
+```
+
+---
+
+## Cấu hình
+
+### Cấu hình config.json
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gemini-flash",
+ "model": "antigravity/gemini-3-flash",
+ "auth_method": "oauth"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model_name": "gemini-flash"
+ }
+ }
+}
+```
+
+### Lưu trữ hồ sơ xác thực
+
+Hồ sơ xác thực được lưu trữ trong `~/.picoclaw/auth.json`:
+
+```json
+{
+ "credentials": {
+ "google-antigravity": {
+ "access_token": "ya29...",
+ "refresh_token": "1//...",
+ "expires_at": "2026-01-01T00:00:00Z",
+ "provider": "google-antigravity",
+ "auth_method": "oauth",
+ "email": "user@example.com",
+ "project_id": "my-project-id"
+ }
+ }
+}
+```
+
+---
+
+## Tạo nhà cung cấp mới trong PicoClaw
+
+Các nhà cung cấp PicoClaw được triển khai dưới dạng gói Go trong `pkg/providers/`. Để thêm nhà cung cấp mới:
+
+### Triển khai từng bước
+
+#### 1. Tạo file nhà cung cấp
+
+Tạo file Go mới trong `pkg/providers/`:
+
+```
+pkg/providers/
+└── your_provider.go
+```
+
+#### 2. Triển khai interface Provider
+
+Nhà cung cấp của bạn phải triển khai interface `Provider` được định nghĩa trong `pkg/providers/types.go`:
+
+```go
+package providers
+
+type YourProvider struct {
+ apiKey string
+ apiBase string
+}
+
+func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider {
+ if apiBase == "" {
+ apiBase = "https://api.your-provider.com/v1"
+ }
+ return &YourProvider{apiKey: apiKey, apiBase: apiBase}
+}
+
+func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error {
+ // Triển khai hoàn thành chat với streaming
+}
+```
+
+#### 3. Đăng ký trong factory
+
+Thêm nhà cung cấp của bạn vào switch giao thức trong `pkg/providers/factory.go`:
+
+```go
+case "your-provider":
+ return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil
+```
+
+#### 4. Thêm cấu hình mặc định (Tùy chọn)
+
+Thêm mục mặc định trong `pkg/config/defaults.go`:
+
+```go
+{
+ ModelName: "your-model",
+ Model: "your-provider/model-name",
+ APIKey: "",
+},
+```
+
+#### 5. Thêm hỗ trợ xác thực (Tùy chọn)
+
+Nếu nhà cung cấp của bạn yêu cầu OAuth hoặc xác thực đặc biệt, thêm case vào `cmd/picoclaw/internal/auth/helpers.go`:
+
+```go
+case "your-provider":
+ authLoginYourProvider()
+```
+
+#### 6. Cấu hình qua `config.json`
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "your-model",
+ "model": "your-provider/model-name",
+ "api_key": "your-api-key",
+ "api_base": "https://api.your-provider.com/v1"
+ }
+ ]
+}
+```
+
+---
+
+## Kiểm thử triển khai của bạn
+
+### Lệnh CLI
+
+```bash
+# Xác thực với nhà cung cấp
+picoclaw auth login --provider your-provider
+
+# Liệt kê mô hình (cho Antigravity)
+picoclaw auth models
+
+# Khởi động gateway
+picoclaw gateway
+
+# Chạy agent với mô hình cụ thể
+picoclaw agent -m "Hello" --model your-model
+```
+
+### Biến môi trường cho kiểm thử
+
+```bash
+# Ghi đè mô hình mặc định
+export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model
+
+# Ghi đè cài đặt nhà cung cấp
+export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]'
+```
+
+---
+
+## Tài liệu tham khảo
+
+- **File nguồn:**
+ - `pkg/providers/antigravity_provider.go` - Triển khai nhà cung cấp Antigravity
+ - `pkg/auth/oauth.go` - Triển khai luồng OAuth
+ - `pkg/auth/store.go` - Lưu trữ thông tin xác thực (`~/.picoclaw/auth.json`)
+ - `pkg/providers/factory.go` - Factory nhà cung cấp và định tuyến giao thức
+ - `pkg/providers/types.go` - Định nghĩa interface nhà cung cấp
+ - `cmd/picoclaw/internal/auth/helpers.go` - Lệnh CLI xác thực
+
+- **Tài liệu:**
+ - `docs/ANTIGRAVITY_USAGE.md` - Hướng dẫn sử dụng Antigravity
+ - `docs/migration/model-list-migration.md` - Hướng dẫn di chuyển
+
+---
+
+## Lưu ý
+
+1. **Dự án Google Cloud:** Antigravity yêu cầu Gemini for Google Cloud được bật trên dự án Google Cloud của bạn
+2. **Hạn mức:** Sử dụng hạn mức dự án Google Cloud (không tính phí riêng)
+3. **Truy cập mô hình:** Các mô hình khả dụng phụ thuộc vào cấu hình dự án Google Cloud của bạn
+4. **Khối suy nghĩ:** Mô hình Claude qua Antigravity yêu cầu xử lý đặc biệt khối suy nghĩ có chữ ký
+5. **Làm sạch schema:** Schema công cụ phải được làm sạch để loại bỏ các từ khóa JSON Schema không được hỗ trợ
+
+---
+
+## Xử lý lỗi thường gặp
+
+### 1. Giới hạn tốc độ (HTTP 429)
+
+Antigravity trả về lỗi 429 khi hạn mức dự án/mô hình đã cạn kiệt. Phản hồi lỗi thường chứa `quotaResetDelay` trong trường `details`.
+
+**Ví dụ lỗi 429:**
+```json
+{
+ "error": {
+ "code": 429,
+ "message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.",
+ "status": "RESOURCE_EXHAUSTED",
+ "details": [
+ {
+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
+ "metadata": {
+ "quotaResetDelay": "4h30m28.060903746s"
+ }
+ }
+ ]
+ }
+}
+```
+
+### 2. Phản hồi trống (Mô hình bị hạn chế)
+
+Một số mô hình có thể xuất hiện trong danh sách mô hình khả dụng nhưng trả về phản hồi trống (200 OK nhưng luồng SSE trống). Điều này thường xảy ra với các mô hình xem trước hoặc bị hạn chế mà dự án hiện tại không có quyền sử dụng.
+
+**Cách xử lý:** Coi phản hồi trống là lỗi, thông báo cho người dùng rằng mô hình có thể bị hạn chế hoặc không hợp lệ cho dự án của họ.
+
+---
+
+## Khắc phục sự cố
+
+### "Token expired" (Token đã hết hạn)
+- Làm mới token OAuth: `picoclaw auth login --provider antigravity`
+
+### "Gemini for Google Cloud is not enabled" (Gemini for Google Cloud chưa được bật)
+- Bật API trong Google Cloud Console của bạn
+
+### "Project not found" (Không tìm thấy dự án)
+- Đảm bảo dự án Google Cloud của bạn đã bật các API cần thiết
+- Kiểm tra xem ID dự án có được lấy chính xác trong quá trình xác thực không
+
+### Mô hình không xuất hiện trong danh sách
+- Xác minh xác thực OAuth đã hoàn tất thành công
+- Kiểm tra lưu trữ hồ sơ xác thực: `~/.picoclaw/auth.json`
+- Chạy lại `picoclaw auth login --provider antigravity`
diff --git a/docs/vi/ANTIGRAVITY_USAGE.md b/docs/vi/ANTIGRAVITY_USAGE.md
new file mode 100644
index 000000000..4a696f770
--- /dev/null
+++ b/docs/vi/ANTIGRAVITY_USAGE.md
@@ -0,0 +1,72 @@
+> Quay lại [README](../../README.vi.md)
+
+# Sử dụng nhà cung cấp Antigravity trong PicoClaw
+
+Hướng dẫn này giải thích cách thiết lập và sử dụng nhà cung cấp **Antigravity** (Google Cloud Code Assist) trong PicoClaw.
+
+## Điều kiện tiên quyết
+
+1. Một tài khoản Google.
+2. Đã kích hoạt Google Cloud Code Assist (thường có sẵn thông qua quy trình giới thiệu "Gemini for Google Cloud").
+
+## 1. Xác thực
+
+Để xác thực với Antigravity, chạy lệnh sau:
+
+```bash
+picoclaw auth login --provider antigravity
+```
+
+### Xác thực thủ công (Headless/VPS)
+Nếu bạn đang chạy trên máy chủ (Coolify/Docker) và không thể truy cập `localhost`, hãy làm theo các bước sau:
+1. Chạy lệnh ở trên.
+2. Sao chép URL được cung cấp và mở nó trong trình duyệt cục bộ của bạn.
+3. Hoàn tất đăng nhập.
+4. Trình duyệt của bạn sẽ chuyển hướng đến URL `localhost:51121` (trang sẽ không tải được).
+5. **Sao chép URL cuối cùng đó** từ thanh địa chỉ trình duyệt.
+6. **Dán nó vào terminal** nơi PicoClaw đang chờ.
+
+PicoClaw sẽ tự động trích xuất mã ủy quyền và hoàn tất quy trình.
+
+## 2. Quản lý mô hình
+
+### Liệt kê các mô hình khả dụng
+Để xem dự án của bạn có quyền truy cập vào những mô hình nào và kiểm tra hạn mức của chúng:
+
+```bash
+picoclaw auth models
+```
+
+### Chuyển đổi mô hình
+Bạn có thể thay đổi mô hình mặc định trong `~/.picoclaw/config.json` hoặc ghi đè qua CLI:
+
+```bash
+# Ghi đè cho một lệnh duy nhất
+picoclaw agent -m "Hello" --model claude-opus-4-6-thinking
+```
+
+## 3. Sử dụng thực tế (Coolify/Docker)
+
+Nếu bạn đang triển khai qua Coolify hoặc Docker, hãy làm theo các bước sau để kiểm tra:
+
+1. **Biến môi trường**:
+ * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash`
+2. **Lưu trữ xác thực**:
+ Nếu bạn đã đăng nhập cục bộ, bạn có thể sao chép thông tin xác thực lên máy chủ:
+ ```bash
+ scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/
+ ```
+ *Hoặc*, chạy lệnh `auth login` một lần trên máy chủ nếu bạn có quyền truy cập terminal.
+
+## 4. Khắc phục sự cố
+
+* **Phản hồi trống**: Nếu một mô hình trả về phản hồi trống, nó có thể bị hạn chế cho dự án của bạn. Hãy thử `gemini-3-flash` hoặc `claude-opus-4-6-thinking`.
+* **429 Giới hạn tốc độ**: Antigravity có hạn mức nghiêm ngặt. PicoClaw sẽ hiển thị "thời gian đặt lại" trong thông báo lỗi nếu bạn đạt đến giới hạn.
+* **404 Không tìm thấy**: Đảm bảo bạn đang sử dụng ID mô hình từ danh sách `picoclaw auth models`. Sử dụng ID ngắn (ví dụ: `gemini-3-flash`) thay vì đường dẫn đầy đủ.
+
+## 5. Tóm tắt các mô hình hoạt động tốt
+
+Dựa trên kiểm tra, các mô hình sau đáng tin cậy nhất:
+* `gemini-3-flash` (Nhanh, khả dụng cao)
+* `gemini-2.5-flash-lite` (Nhẹ)
+* `claude-opus-4-6-thinking` (Mạnh mẽ, bao gồm khả năng suy luận)
diff --git a/docs/vi/chat-apps.md b/docs/vi/chat-apps.md
index 5f527eabe..3680fed69 100644
--- a/docs/vi/chat-apps.md
+++ b/docs/vi/chat-apps.md
@@ -8,22 +8,22 @@ Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix
> **Lưu ý**: Tất cả các kênh dựa trên webhook (LINE, WeCom, v.v.) được phục vụ trên một máy chủ HTTP Gateway chung (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Không có port riêng cho từng kênh. Lưu ý: Feishu sử dụng chế độ WebSocket/SDK và không sử dụng máy chủ HTTP webhook chung.
-| Channel | Setup |
-| ------------ | ---------------------------------- |
-| **Telegram** | Easy (just a token) |
-| **Discord** | Easy (bot token + intents) |
-| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
-| **Matrix** | Medium (homeserver + bot access token) |
-| **QQ** | Easy (AppID + AppSecret) |
-| **DingTalk** | Medium (app credentials) |
-| **LINE** | Medium (credentials + webhook URL) |
-| **WeCom AI Bot** | Medium (Token + AES key) |
-| **Feishu** | Medium (App ID + Secret, WebSocket mode) |
-| **Slack** | Medium (Bot token + App token) |
-| **IRC** | Medium (server + TLS config) |
-| **OneBot** | Medium (QQ via OneBot protocol) |
-| **MaixCam** | Easy (Sipeed hardware integration) |
-| **Pico** | Native PicoClaw protocol |
+| Kênh | Độ khó | Mô tả | Tài liệu |
+| -------------------- | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
+| **Telegram** | ⭐ Dễ | Khuyến nghị, chuyển giọng nói thành văn bản, long polling (không cần IP công khai) | [Tài liệu](../channels/telegram/README.vi.md) |
+| **Discord** | ⭐ Dễ | Socket Mode, hỗ trợ nhóm/DM, hệ sinh thái bot phong phú | [Tài liệu](../channels/discord/README.vi.md) |
+| **WhatsApp** | ⭐ Dễ | Bản địa (quét QR) hoặc Bridge URL | [Tài liệu](#whatsapp) |
+| **Slack** | ⭐ Dễ | **Socket Mode** (không cần IP công khai), doanh nghiệp | [Tài liệu](../channels/slack/README.vi.md) |
+| **Matrix** | ⭐⭐ Trung bình | Giao thức liên kết, hỗ trợ tự lưu trữ | [Tài liệu](../channels/matrix/README.vi.md) |
+| **QQ** | ⭐⭐ Trung bình | API bot chính thức, cộng đồng Trung Quốc | [Tài liệu](../channels/qq/README.vi.md) |
+| **DingTalk** | ⭐⭐ Trung bình | Chế độ Stream (không cần IP công khai), doanh nghiệp | [Tài liệu](../channels/dingtalk/README.vi.md) |
+| **LINE** | ⭐⭐⭐ Nâng cao | Yêu cầu HTTPS Webhook | [Tài liệu](../channels/line/README.vi.md) |
+| **WeCom (企业微信)** | ⭐⭐⭐ Nâng cao | Bot nhóm (Webhook), ứng dụng tùy chỉnh (API), AI Bot | [Bot](../channels/wecom/wecom_bot/README.vi.md) / [App](../channels/wecom/wecom_app/README.vi.md) / [AI Bot](../channels/wecom/wecom_aibot/README.vi.md) |
+| **Feishu (飞书)** | ⭐⭐⭐ Nâng cao | Cộng tác doanh nghiệp, nhiều tính năng | [Tài liệu](../channels/feishu/README.vi.md) |
+| **IRC** | ⭐⭐ Trung bình | Máy chủ + cấu hình TLS | - |
+| **OneBot** | ⭐⭐ Trung bình | Tương thích NapCat/Go-CQHTTP, hệ sinh thái cộng đồng | [Tài liệu](../channels/onebot/README.vi.md) |
+| **MaixCam** | ⭐ Dễ | Kênh tích hợp phần cứng cho camera AI Sipeed | [Tài liệu](../channels/maixcam/README.vi.md) |
+| **Pico** | ⭐ Dễ | Kênh giao thức bản địa PicoClaw | |
Telegram (Khuyến nghị)
@@ -168,12 +168,13 @@ Nếu `session_store_path` trống, phiên được lưu tại `/what
QQ
-**1. Tạo bot**
+**Thiết lập nhanh (khuyến nghị)**
-- Truy cập [QQ Open Platform](https://q.qq.com/#)
-- Tạo ứng dụng → Lấy **AppID** và **AppSecret**
+QQ Open Platform cung cấp trang thiết lập một chạm cho bot tương thích OpenClaw:
-**2. Cấu hình**
+1. Mở [QQ Bot Quick Start](https://q.qq.com/qqbot/openclaw/index.html) và quét mã QR để đăng nhập
+2. Bot được tạo tự động — sao chép **App ID** và **App Secret**
+3. Cấu hình PicoClaw:
```json
{
@@ -188,13 +189,20 @@ Nếu `session_store_path` trống, phiên được lưu tại `/what
}
```
-> Đặt `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định số QQ để giới hạn truy cập.
+4. Chạy `picoclaw gateway` và mở QQ để trò chuyện với bot của bạn
-**3. Chạy**
+> App Secret chỉ hiển thị một lần. Lưu ngay lập tức — xem lại sẽ buộc phải đặt lại.
+>
+> Bot được tạo qua trang thiết lập nhanh ban đầu chỉ dành cho người tạo và không hỗ trợ chat nhóm. Để bật quyền truy cập nhóm, cấu hình chế độ sandbox trên [QQ Open Platform](https://q.qq.com/).
-```bash
-picoclaw gateway
-```
+**Thiết lập thủ công**
+
+Nếu bạn muốn tạo bot thủ công:
+
+* Đăng nhập tại [QQ Open Platform](https://q.qq.com/) để đăng ký làm nhà phát triển
+* Tạo bot QQ — tùy chỉnh avatar và tên
+* Sao chép **App ID** và **App Secret** từ cài đặt bot
+* Cấu hình như trên và chạy `picoclaw gateway`
@@ -229,8 +237,31 @@ picoclaw gateway
```bash
picoclaw gateway
```
+
+
+MaixCam
+
+Kênh tích hợp được thiết kế đặc biệt cho phần cứng camera AI Sipeed.
+
+```json
+{
+ "channels": {
+ "maixcam": {
+ "enabled": true
+ }
+ }
+}
+```
+
+```bash
+picoclaw gateway
+```
+
+
+
+
Matrix
@@ -261,7 +292,7 @@ picoclaw gateway
picoclaw gateway
```
-Để xem đầy đủ các tùy chọn (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), xem [Hướng Dẫn Cấu Hình Kênh Matrix](docs/channels/matrix/README.md).
+Để xem đầy đủ các tùy chọn (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), xem [Hướng Dẫn Cấu Hình Kênh Matrix](../channels/matrix/README.md).
@@ -322,7 +353,7 @@ PicoClaw hỗ trợ ba loại tích hợp WeCom:
**Tùy chọn 2: WeCom App (App Tùy chỉnh)** - Nhiều tính năng hơn, nhắn tin chủ động, chỉ chat riêng
**Tùy chọn 3: WeCom AI Bot (AI Bot)** - AI Bot chính thức, phản hồi streaming, hỗ trợ chat nhóm & riêng
-Xem [Hướng Dẫn Cấu Hình WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) để biết hướng dẫn thiết lập chi tiết.
+Xem [Hướng Dẫn Cấu Hình WeCom AI Bot](../channels/wecom/wecom_aibot/README.vi.md) để biết hướng dẫn thiết lập chi tiết.
**Thiết Lập Nhanh - WeCom Bot:**
@@ -396,7 +427,7 @@ picoclaw gateway
**1. Tạo AI Bot**
* Truy cập Console Quản Trị WeCom → Quản Lý App → AI Bot
-* Trong cài đặt AI Bot, cấu hình callback URL: `http://your-server:18791/webhook/wecom-aibot`
+* Trong cài đặt AI Bot, cấu hình callback URL: `http://your-server:18790/webhook/wecom-aibot`
* Sao chép **Token** và nhấp "Tạo Ngẫu Nhiên" cho **EncodingAESKey**
**2. Cấu hình**
@@ -426,3 +457,169 @@ picoclaw gateway
> **Lưu ý**: WeCom AI Bot sử dụng giao thức streaming pull — không lo timeout phản hồi. Tác vụ dài (>30 giây) tự động chuyển sang gửi qua `response_url` push.
+
+
+Feishu (Lark)
+
+PicoClaw kết nối với Feishu qua chế độ WebSocket/SDK — không cần URL webhook công khai hay máy chủ callback.
+
+**1. Tạo ứng dụng**
+
+* Truy cập [Feishu Open Platform](https://open.feishu.cn/) và tạo ứng dụng
+* Trong cài đặt ứng dụng, bật khả năng **Bot**
+* Tạo phiên bản và xuất bản ứng dụng (ứng dụng phải được xuất bản mới có hiệu lực)
+* Sao chép **App ID** (bắt đầu bằng `cli_`) và **App Secret**
+
+**2. Cấu hình**
+
+```json
+{
+ "channels": {
+ "feishu": {
+ "enabled": true,
+ "app_id": "cli_xxx",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+Tùy chọn: `encrypt_key` và `verification_token` để mã hóa sự kiện (khuyến nghị cho môi trường production).
+
+**3. Chạy và trò chuyện**
+
+```bash
+picoclaw gateway
+```
+
+Mở Feishu, tìm tên bot của bạn và bắt đầu trò chuyện. Bạn cũng có thể thêm bot vào nhóm — sử dụng `group_trigger.mention_only: true` để chỉ phản hồi khi được @mention.
+
+Để xem đầy đủ các tùy chọn, xem [Hướng Dẫn Cấu Hình Kênh Feishu](../channels/feishu/README.vi.md).
+
+
+
+
+Slack
+
+**1. Tạo ứng dụng Slack**
+
+* Truy cập [Slack API](https://api.slack.com/apps) và tạo ứng dụng mới
+* Trong **OAuth & Permissions**, thêm các scope bot: `chat:write`, `app_mentions:read`, `im:history`, `im:read`, `im:write`
+* Cài đặt ứng dụng vào workspace của bạn
+* Sao chép **Bot Token** (`xoxb-...`) và **App-Level Token** (`xapp-...`, bật Socket Mode để lấy token này)
+
+**2. Cấu hình**
+
+```json
+{
+ "channels": {
+ "slack": {
+ "enabled": true,
+ "bot_token": "xoxb-YOUR-BOT-TOKEN",
+ "app_token": "xapp-YOUR-APP-TOKEN",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**3. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+IRC
+
+**1. Cấu hình**
+
+```json
+{
+ "channels": {
+ "irc": {
+ "enabled": true,
+ "server": "irc.libera.chat:6697",
+ "tls": true,
+ "nick": "picoclaw-bot",
+ "channels": ["#your-channel"],
+ "password": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+Tùy chọn: `nickserv_password` để xác thực NickServ, `sasl_user`/`sasl_password` để xác thực SASL.
+
+**2. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+Bot sẽ kết nối đến máy chủ IRC và tham gia các kênh đã chỉ định.
+
+
+
+
+OneBot (QQ qua giao thức OneBot)
+
+OneBot là giao thức mở cho bot QQ. PicoClaw kết nối với bất kỳ triển khai tương thích OneBot v11 nào (ví dụ: [Lagrange](https://github.com/LagrangeDev/Lagrange.Core), [NapCat](https://github.com/NapNeko/NapCatQQ)) qua WebSocket.
+
+**1. Thiết lập triển khai OneBot**
+
+Cài đặt và chạy framework bot QQ tương thích OneBot v11. Bật máy chủ WebSocket của nó.
+
+**2. Cấu hình**
+
+```json
+{
+ "channels": {
+ "onebot": {
+ "enabled": true,
+ "ws_url": "ws://127.0.0.1:8080",
+ "access_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| Trường | Mô tả |
+|--------|-------|
+| `ws_url` | URL WebSocket của triển khai OneBot |
+| `access_token` | Token truy cập để xác thực (nếu đã cấu hình trong OneBot) |
+| `reconnect_interval` | Khoảng thời gian kết nối lại tính bằng giây (mặc định: 5) |
+
+**3. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+MaixCam
+
+Kênh tích hợp được thiết kế đặc biệt cho phần cứng camera AI Sipeed.
+
+```json
+{
+ "channels": {
+ "maixcam": {
+ "enabled": true
+ }
+ }
+}
+```
+
+```bash
+picoclaw gateway
+```
+
+
diff --git a/docs/vi/configuration.md b/docs/vi/configuration.md
index 847f28e60..a21929359 100644
--- a/docs/vi/configuration.md
+++ b/docs/vi/configuration.md
@@ -57,7 +57,7 @@ Mặc định, skill được tải từ:
1. `~/.picoclaw/workspace/skills` (workspace)
2. `~/.picoclaw/skills` (global)
-3. `/skills` (builtin)
+3. `<đường-dẫn-nhúng-khi-build>/skills` (tích hợp)
Cho thiết lập nâng cao/test, bạn có thể ghi đè thư mục gốc skill builtin với:
diff --git a/docs/vi/credential_encryption.md b/docs/vi/credential_encryption.md
new file mode 100644
index 000000000..9ba24588b
--- /dev/null
+++ b/docs/vi/credential_encryption.md
@@ -0,0 +1,159 @@
+> Quay lại [README](../../README.vi.md)
+
+# Mã hóa Thông tin Xác thực
+
+PicoClaw hỗ trợ mã hóa các giá trị `api_key` trong các mục cấu hình `model_list`.
+Các khóa đã mã hóa được lưu trữ dưới dạng chuỗi `enc://` và được giải mã tự động khi khởi động.
+
+---
+
+## Bắt đầu Nhanh
+
+**1. Đặt cụm mật khẩu**
+
+```bash
+export PICOCLAW_KEY_PASSPHRASE="your-passphrase"
+```
+
+**2. Mã hóa khóa API**
+
+Chạy `picoclaw onboard` — nó yêu cầu nhập cụm mật khẩu và tạo khóa SSH,
+sau đó tự động mã hóa lại tất cả các mục `api_key` dạng văn bản thuần trong cấu hình
+ở lần gọi `SaveConfig` tiếp theo. Giá trị `enc://` kết quả sẽ có dạng:
+
+```
+enc://AAAA...base64...
+```
+
+**3. Dán kết quả vào cấu hình**
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-4o",
+ "model": "openai/gpt-4o",
+ "api_key": "enc://AAAA...base64...",
+ "api_base": "https://api.openai.com/v1"
+ }
+ ]
+}
+```
+
+---
+
+## Các Định dạng `api_key` được Hỗ trợ
+
+| Định dạng | Ví dụ | Hành vi |
+|-----------|-------|---------|
+| Văn bản thuần | `sk-abc123` | Sử dụng nguyên trạng |
+| Tham chiếu tệp | `file://openai.key` | Nội dung được đọc từ cùng thư mục với tệp cấu hình |
+| Đã mã hóa | `enc://` | Giải mã khi khởi động bằng `PICOCLAW_KEY_PASSPHRASE` |
+| Trống | `""` | Truyền qua không thay đổi (dùng với `auth_method: oauth`) |
+
+---
+
+## Thiết kế Mật mã
+
+### Dẫn xuất Khóa
+
+Mã hóa sử dụng **HKDF-SHA256** với khóa riêng SSH làm yếu tố thứ hai.
+
+```
+sshHash = SHA256(ssh_private_key_file_bytes)
+ikm = HMAC-SHA256(key=sshHash, message=passphrase)
+aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes)
+```
+
+### Mã hóa
+
+```
+AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key)
+```
+
+### Định dạng Truyền tải
+
+```
+enc://
+```
+
+| Trường | Kích thước | Mô tả |
+|--------|-----------|-------|
+| `salt` | 16 byte | Ngẫu nhiên mỗi lần mã hóa; đưa vào HKDF |
+| `nonce` | 12 byte | Ngẫu nhiên mỗi lần mã hóa; IV của AES-GCM |
+| `ciphertext` | thay đổi | Bản mã AES-256-GCM + thẻ xác thực 16 byte |
+
+Thẻ xác thực GCM được tự động nối vào bản mã. Bất kỳ sự giả mạo nào đều khiến giải mã thất bại với lỗi thay vì trả về văn bản thuần bị hỏng.
+
+### Hiệu suất
+
+| Thao tác | Thời gian (ARM Cortex-A) |
+|----------|--------------------------|
+| Dẫn xuất khóa (HKDF) | < 1 ms |
+| Giải mã AES-256-GCM | < 1 ms |
+| **Tổng chi phí khởi động** | **< 2 ms mỗi khóa** |
+
+---
+
+## Bảo mật Hai Yếu tố với Khóa SSH
+
+Khi khóa riêng SSH được cung cấp, việc phá vỡ mã hóa yêu cầu **cả hai**:
+
+1. **Cụm mật khẩu** (`PICOCLAW_KEY_PASSPHRASE`)
+2. **Tệp khóa riêng SSH**
+
+Điều này có nghĩa là chỉ rò rỉ tệp cấu hình không đủ để khôi phục khóa API, ngay cả khi cụm mật khẩu yếu. Khóa SSH đóng góp 256 bit entropy (Ed25519) bất kể độ mạnh của cụm mật khẩu.
+
+### Mô hình Mối đe dọa
+
+| Kẻ tấn công có | Có thể giải mã? |
+|----------------|-----------------|
+| Chỉ tệp cấu hình | Không — cần cụm mật khẩu + khóa SSH |
+| Chỉ khóa SSH | Không — cần cụm mật khẩu |
+| Chỉ cụm mật khẩu | Không — cần khóa SSH |
+| Tệp cấu hình + khóa SSH + cụm mật khẩu | Có — xâm phạm hoàn toàn |
+
+---
+
+## Biến Môi trường
+
+| Biến | Bắt buộc | Mô tả |
+|------|----------|-------|
+| `PICOCLAW_KEY_PASSPHRASE` | Có (cho `enc://`) | Cụm mật khẩu dùng để dẫn xuất khóa |
+| `PICOCLAW_SSH_KEY_PATH` | Không | Đường dẫn đến khóa riêng SSH. Nếu không đặt, tự động phát hiện từ `~/.ssh/picoclaw_ed25519.key` |
+
+### Tự động Phát hiện Khóa SSH
+
+Nếu `PICOCLAW_SSH_KEY_PATH` không được đặt, PicoClaw tìm khóa chuyên dụng:
+
+```
+~/.ssh/picoclaw_ed25519.key
+```
+
+Tệp chuyên dụng này tránh xung đột với các khóa SSH hiện có của người dùng.
+Chạy `picoclaw onboard` để tạo tự động.
+
+`os.UserHomeDir()` được sử dụng để phân giải thư mục home đa nền tảng (đọc `USERPROFILE` trên Windows, `HOME` trên Unix/macOS).
+
+> **Lưu ý:** Tệp khóa SSH là bắt buộc cho mã hóa thông tin xác thực. Nếu không tìm thấy khóa và `PICOCLAW_SSH_KEY_PATH` không được đặt, mã hóa/giải mã sẽ thất bại. Chạy `picoclaw onboard` để tạo khóa tự động.
+
+---
+
+## Di chuyển
+
+Vì tài liệu bí mật duy nhất là `PICOCLAW_KEY_PASSPHRASE` và tệp khóa riêng SSH, việc di chuyển rất đơn giản:
+
+1. Sao chép tệp cấu hình sang máy mới.
+2. Đặt `PICOCLAW_KEY_PASSPHRASE` với cùng giá trị.
+3. Sao chép tệp khóa riêng SSH đến cùng đường dẫn (hoặc đặt `PICOCLAW_SSH_KEY_PATH` đến vị trí mới).
+
+Không cần mã hóa lại.
+
+---
+
+## Lưu ý về Bảo mật
+
+- **Cả cụm mật khẩu và khóa SSH đều bắt buộc.** Khóa SSH đóng vai trò yếu tố thứ hai — không có nó, mã hóa/giải mã sẽ thất bại. Chạy `picoclaw onboard` để tạo khóa nếu chưa tồn tại.
+- **Khóa SSH chỉ đọc khi chạy.** PicoClaw không bao giờ ghi hoặc sửa đổi tệp khóa SSH.
+- **Khóa văn bản thuần vẫn được hỗ trợ.** Các cấu hình hiện có không dùng `enc://` không bị ảnh hưởng.
+- **Định dạng `enc://` được quản lý phiên bản** thông qua trường `info` của HKDF (`picoclaw-credential-v1`), cho phép nâng cấp thuật toán trong tương lai mà không làm hỏng các giá trị đã mã hóa hiện có.
diff --git a/docs/vi/debug.md b/docs/vi/debug.md
new file mode 100644
index 000000000..69583d486
--- /dev/null
+++ b/docs/vi/debug.md
@@ -0,0 +1,36 @@
+# Gỡ lỗi PicoClaw
+
+> Quay lại [README](../../README.vi.md)
+
+PicoClaw thực hiện nhiều tương tác phức tạp ở hậu trường cho mỗi yêu cầu nhận được — từ định tuyến tin nhắn và đánh giá độ phức tạp, đến thực thi công cụ và thích ứng với lỗi mô hình. Khả năng xem chính xác những gì đang xảy ra là rất quan trọng, không chỉ để khắc phục các sự cố tiềm ẩn, mà còn để thực sự hiểu cách agent hoạt động.
+
+## Khởi động PicoClaw ở chế độ gỡ lỗi
+
+Để nhận thông tin chi tiết về những gì agent đang thực hiện (yêu cầu LLM, lệnh gọi công cụ, định tuyến tin nhắn), bạn có thể khởi động gateway PicoClaw với cờ gỡ lỗi:
+
+```bash
+picoclaw gateway --debug
+# or
+picoclaw gateway -d
+```
+
+Ở chế độ này, hệ thống sẽ định dạng log chi tiết và hiển thị bản xem trước của prompt hệ thống và kết quả thực thi công cụ.
+
+## Tắt cắt ngắn log (log đầy đủ)
+
+Theo mặc định, PicoClaw cắt ngắn các chuỗi rất dài (như *Prompt Hệ thống* hoặc kết quả JSON lớn) trong log gỡ lỗi để giữ cho console dễ đọc.
+
+Nếu bạn cần kiểm tra đầu ra đầy đủ của một lệnh hoặc payload chính xác được gửi đến mô hình LLM, bạn có thể sử dụng cờ `--no-truncate`.
+
+**Lưu ý:** Cờ này *chỉ* hoạt động khi kết hợp với chế độ `--debug`.
+
+```bash
+picoclaw gateway --debug --no-truncate
+
+```
+
+Khi cờ này được kích hoạt, chức năng cắt ngắn toàn cục sẽ bị vô hiệu hóa. Điều này cực kỳ hữu ích để:
+
+* Xác minh cú pháp chính xác của các tin nhắn được gửi đến nhà cung cấp.
+* Đọc đầu ra đầy đủ của các công cụ như `exec`, `web_fetch` hoặc `read_file`.
+* Gỡ lỗi lịch sử phiên được lưu trong bộ nhớ.
diff --git a/docs/vi/docker.md b/docs/vi/docker.md
index 519ace5ba..eddc20a75 100644
--- a/docs/vi/docker.md
+++ b/docs/vi/docker.md
@@ -12,6 +12,7 @@ git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
# 2. Lần chạy đầu tiên — tự động tạo docker/data/config.json rồi thoát
+# (chỉ kích hoạt khi cả config.json và workspace/ đều không tồn tại)
docker compose -f docker/docker-compose.yml --profile gateway up
# Container hiển thị "First-run setup complete." và dừng lại.
diff --git a/docs/vi/hardware-compatibility.md b/docs/vi/hardware-compatibility.md
new file mode 100644
index 000000000..8315c049e
--- /dev/null
+++ b/docs/vi/hardware-compatibility.md
@@ -0,0 +1,152 @@
+> Quay lại [README](../../README.vi.md)
+
+# 🖥️ PicoClaw Danh sách tương thích phần cứng
+
+PicoClaw chạy được trên hầu hết mọi thiết bị Linux. Trang này ghi nhận các chip, sản phẩm và bo mạch phát triển đã được xác minh.
+
+**Phần cứng của bạn chưa có trong danh sách?** Gửi PR để thêm vào! Các nhà sản xuất phần cứng được hoan nghênh đóng góp và đồng quảng bá.
+
+---
+
+## 1. Hỗ trợ chip đã xác minh
+
+### x86
+
+| Nhà sản xuất | Chip | Ghi chú |
+|--------------|------|---------|
+| Intel | Any x86 CPU (i386+) | Tất cả bộ xử lý desktop/server/laptop |
+| AMD | Any x86 CPU | Tất cả bộ xử lý desktop/server/laptop |
+
+### ARM
+
+| Kiến trúc phụ | Chip tiêu biểu | Ghi chú |
+|----------------|----------------|---------|
+| ARMv6 | [BCM2835](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2835) (Raspberry Pi 1/Zero) | Đơn nhân ARM1176JZF-S |
+| ARMv7 | [Allwinner V3s](https://linux-sunxi.org/V3s) | Đơn nhân Cortex-A7, dùng trong LicheePi Zero |
+| ARM64 | [Allwinner H618](https://linux-sunxi.org/H618) | Bốn nhân Cortex-A53, dùng trong Orange Pi Zero 3 |
+| ARM64 | [BCM2711](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2711) (Raspberry Pi 4) | Bốn nhân Cortex-A72 |
+| ARM64 | [BCM2712](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2712) (Raspberry Pi 5) | Bốn nhân Cortex-A76 |
+| ARM64 | [AX630C](https://www.axera-tech.com/) (爱芯元智) | Hai nhân Cortex-A53 + NPU, dùng trong NanoKVM-Pro / MaixCAM2 |
+
+### RISC-V (riscv64)
+
+| Nhà sản xuất | Chip | Lõi | Ghi chú |
+|--------------|------|-----|---------|
+| [SOPHGO (算能)](https://www.sophgo.com/) | SG2002 | C906 @ 1GHz | 256MB DDR3 tích hợp, dùng trong LicheeRV-Nano / NanoKVM / MaixCAM |
+| [Allwinner (全志)](https://www.allwinnertech.com/) | V861 | Dual C907 | 128MB DDR3L tích hợp, 1 TOPS NPU, camera AI 4K SiP |
+| [Allwinner (全志)](https://www.allwinnertech.com/) | V881 | C907 | Dòng camera AI RISC-V |
+| [Arterytek (匠芯创)](https://www.arterytek.com/) | D213 | RISC-V | Dùng trong HaaS506-LD1 RTU công nghiệp |
+| [SpacemiT (进迭)](https://www.spacemit.com/) | K1 | 8x X60 @ 1.8GHz | Dùng trong Milk-V Jupiter, BananaPi BPI-F3 |
+| [SpacemiT (进迭)](https://www.spacemit.com/) | K3 | 8x X100 @ 2.5GHz | Tuân thủ RVA23, RVV 1024-bit, suy luận AI FP8 |
+| [Zhihe (知合)](https://www.zhihe-tech.com/) | A210 | High-perf RISC-V | 8 lõi, 16MB cache L3, cấp desktop |
+| [Canaan (嘉楠)](https://www.canaan-creative.com/) | K230 | Dual C908 @ 1.6GHz | 6 TOPS KPU, dùng trong CanMV-K230 |
+
+### MIPS
+
+| Nhà sản xuất | Chip | Ghi chú |
+|--------------|------|---------|
+| MediaTek | [MT7620](https://www.mediatek.com/products/home-networking/mt7620) | MIPS24KEc @ 580MHz, dùng trong nhiều router OpenWrt (vd. Xiaomi Router 3G) |
+
+### LoongArch (loong64)
+
+| Nhà sản xuất | Chip | Ghi chú |
+|--------------|------|---------|
+| [Loongson (龙芯)](https://www.loongson.cn/) | 3A5000 | Bốn nhân LA464 @ 2.5GHz, desktop/máy trạm |
+| [Loongson (龙芯)](https://www.loongson.cn/) | 3A6000 | Bốn nhân 4C/8T @ 2.5GHz, IPC tương đương Intel thế hệ 10 |
+| [Loongson (龙芯)](https://www.loongson.cn/) | 2K1000LA | Hai nhân @ 1GHz, ứng dụng công nghiệp/IoT |
+
+---
+
+## 2. Sản phẩm đã xác minh (theo ngày phát hành)
+
+Sản phẩm tiêu dùng, router và thiết bị công nghiệp đã được kiểm thử với PicoClaw.
+
+| Năm | Sản phẩm | Kiến trúc | SoC | RAM | Danh mục |
+|-----|----------|-----------|-----|-----|----------|
+| 2009 | Nokia N900 | ARM (A8) | OMAP3430 | 256MB | Điện thoại thông minh |
+| 2012 | Samsung Galaxy Note 10.1 (N8000) | ARM (A9) | Exynos 4412 | 2GB | Máy tính bảng |
+| 2016 | Xiaomi Router 3G (小米路由器3G) | MIPS | MT7620 | 256MB | Router (OpenWrt) |
+| 2018 | Phicomm N1 (斐讯N1) | ARM64 (A53) | S905D | 2GB | TV Box / Máy chủ gia đình |
+| 2019 | Xiaomi AI Speaker (小爱音箱) | ARM64 (A53) | — | 256MB | Loa thông minh |
+| 2024 | [NanoKVM](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html) | RISC-V | SG2002 | 256MB | IP-KVM |
+| 2025 | HaaS506-LD1 | RISC-V | D213 | 128MB | RTU công nghiệp |
+| 2025 | [NanoKVM-Pro](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM_Pro/introduction.html) | ARM64 (A53) | AX630C | 1GB | IP-KVM Pro |
+| 2026 | [MaixCAM2](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | ARM64 (A53) | AX630C | 1/4GB | Camera AI 4K |
+
+---
+
+## 3. Bo mạch phát triển đã xác minh (theo ngày phát hành)
+
+| Năm | Bo mạch | Kiến trúc | SoC | RAM | Liên kết mua |
+|-----|---------|-----------|-----|-----|--------------|
+| 2012 | [Raspberry Pi 1 Model B](https://www.raspberrypi.com/products/) | ARMv6 | BCM2835 | 512MB | — |
+| 2015 | [Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) | ARMv7 (A7) | BCM2836 | 1GB | — |
+| 2015 | [Raspberry Pi Zero](https://www.raspberrypi.com/products/raspberry-pi-zero/) | ARMv6 | BCM2835 | 512MB | — |
+| 2016 | [Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) | ARM64 (A53) | BCM2837 | 1GB | — |
+| 2017 | [LicheePi Zero](https://wiki.sipeed.com/hardware/en/lichee/Zero/Zero.html) | ARMv7 (A7) | Allwinner V3s | 64MB | [Sipeed](https://sipeed.com/) |
+| 2019 | [Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) | ARM64 (A72) | BCM2711 | 1~8GB | [RPi](https://www.raspberrypi.com/) |
+| 2023 | [Raspberry Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/) | ARM64 (A76) | BCM2712 | 2~8GB | [RPi](https://www.raspberrypi.com/) |
+| 2024 | [LicheeRV-Nano](https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html) | RISC-V | SG2002 | 256MB | [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) |
+| 2024 | [MaixCAM-Pro](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | RISC-V | SG2002 | 256MB | [Sipeed](https://sipeed.com/) |
+| 2024 | [Milk-V Duo 64M](https://milkv.io/docs/duo/getting-started/duo) | RISC-V | CV1800B | 64MB | [Milk-V](https://milkv.io/) |
+| 2024 | [CanMV-K230](https://developer.canaan-creative.com/k230_canmv/en/main/) | RISC-V | K230 | 512MB | [Canaan](https://www.canaan-creative.com/) |
+
+---
+
+## 4. Cũng hoạt động trên
+
+### Điện thoại Android (qua Termux)
+
+Bất kỳ điện thoại Android ARM64 nào (2015+) với 1GB+ RAM. Cài đặt [Termux](https://github.com/termux/termux-app), sử dụng `proot` để chạy PicoClaw.
+
+> Xem [README: Chạy trên điện thoại Android cũ](../../README.vi.md#-run-on-old-android-phones) để biết hướng dẫn cài đặt.
+
+### Desktop / Máy chủ / Đám mây
+
+| Nền tảng | Ghi chú |
+|----------|---------|
+| x86_64 Linux | Binary gốc, không phụ thuộc |
+| x86_64 Windows | Binary gốc |
+| macOS (Intel / Apple Silicon) | Binary gốc |
+| Docker (any platform) | `docker compose` một dòng lệnh, xem [Hướng dẫn Docker](docker.md) |
+| OpenWrt routers | Bản dựng MIPS/ARM, yêu cầu >32MB RAM trống |
+| FreeBSD / NetBSD | Có bản dựng x86_64 và arm64 |
+
+---
+
+## 5. Yêu cầu tối thiểu
+
+| Tài nguyên | Tối thiểu | Khuyến nghị |
+|------------|-----------|-------------|
+| RAM | 10MB trống | 32MB+ trống |
+| Lưu trữ | 20MB (binary) | 50MB+ (với workspace) |
+| CPU | Bất kỳ (đơn nhân 0.6GHz+) | — |
+| OS | Linux (kernel 3.x+) | Linux 5.x+ |
+| Mạng | Bắt buộc (cho các lệnh gọi API LLM) | Ethernet hoặc WiFi |
+
+---
+
+## 6. Cách kiểm thử và đóng góp
+
+```bash
+# 1. Tải xuống cho kiến trúc của bạn
+wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
+tar xzf picoclaw_Linux_arm64.tar.gz
+
+# 2. Khởi tạo
+./picoclaw onboard
+
+# 3. Kiểm thử
+./picoclaw agent -m "Hello, what board am I running on?"
+```
+
+Các bản dựng có sẵn: `linux-amd64`, `linux-arm64`, `linux-arm`, `linux-riscv64`, `linux-loong64`, `linux-mipsle`
+
+### Thêm phần cứng của bạn
+
+1. Fork kho lưu trữ này
+2. Thêm chip / sản phẩm / bo mạch của bạn vào bảng tương ứng
+3. Bao gồm: tên, kiến trúc, SoC, RAM, năm và liên kết nếu có
+4. Gửi PR
+
+Nhà sản xuất phần cứng: muốn thêm hỗ trợ chính thức hoặc đồng quảng bá? Mở issue hoặc liên hệ qua [Discord](https://discord.gg/V4sAZ9XWpN).
diff --git a/docs/vi/providers.md b/docs/vi/providers.md
index f7543eec3..09b51c56b 100644
--- a/docs/vi/providers.md
+++ b/docs/vi/providers.md
@@ -93,7 +93,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
],
"agents": {
"defaults": {
- "model": "gpt-5.4"
+ "model_name": "gpt-5.4"
}
}
}
@@ -266,13 +266,13 @@ Cấu hình `providers` cũ đã **ngừng hỗ trợ** nhưng vẫn được h
],
"agents": {
"defaults": {
- "model": "glm-4.7"
+ "model_name": "glm-4.7"
}
}
}
```
-Để xem hướng dẫn di chuyển chi tiết, xem [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
+Để xem hướng dẫn di chuyển chi tiết, xem [migration/model-list-migration.md](../migration/model-list-migration.md).
### Kiến Trúc Provider
@@ -298,7 +298,7 @@ PicoClaw định tuyến provider theo họ giao thức:
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
+ "model_name": "glm-4.7",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
@@ -328,12 +328,11 @@ picoclaw agent -m "Hello"
{
"agents": {
"defaults": {
- "model": "anthropic/claude-opus-4-5"
+ "model_name": "anthropic/claude-opus-4-5"
}
},
"session": {
- "dm_scope": "per-channel-peer",
- "backlog_limit": 20
+ "dm_scope": "per-channel-peer"
},
"providers": {
"openrouter": {
diff --git a/docs/vi/troubleshooting.md b/docs/vi/troubleshooting.md
index d74153aa3..961c932aa 100644
--- a/docs/vi/troubleshooting.md
+++ b/docs/vi/troubleshooting.md
@@ -16,7 +16,7 @@
**Cách sửa:** Trong `~/.picoclaw/config.json` (hoặc đường dẫn cấu hình của bạn):
-1. **agents.defaults.model** phải khớp với một `model_name` trong `model_list` (ví dụ: `"openrouter-free"`).
+1. **agents.defaults.model_name** phải khớp với một `model_name` trong `model_list` (ví dụ: `"openrouter-free"`).
2. **model** của mục đó phải là ID mô hình OpenRouter hợp lệ, ví dụ:
- `"openrouter/free"` – tầng miễn phí tự động
- `"google/gemini-2.0-flash-exp:free"`
@@ -28,7 +28,7 @@ Ví dụ:
{
"agents": {
"defaults": {
- "model": "openrouter-free"
+ "model_name": "openrouter-free"
}
},
"model_list": [
diff --git a/docs/zh/ANTIGRAVITY_AUTH.md b/docs/zh/ANTIGRAVITY_AUTH.md
new file mode 100644
index 000000000..db7c81dea
--- /dev/null
+++ b/docs/zh/ANTIGRAVITY_AUTH.md
@@ -0,0 +1,809 @@
+> 返回 [README](../../README.zh.md)
+
+# Antigravity 认证与集成指南
+
+## 概述
+
+**Antigravity**(Google Cloud Code Assist)是由 Google 支持的 AI 模型提供商,通过 Google 的云基础设施提供对 Claude Opus 4.6 和 Gemini 等模型的访问。本文档提供了关于认证工作原理、如何获取模型以及如何在 PicoClaw 中实现新提供商的完整指南。
+
+---
+
+## 目录
+
+1. [认证流程](#认证流程)
+2. [OAuth 实现细节](#oauth-实现细节)
+3. [令牌管理](#令牌管理)
+4. [模型列表获取](#模型列表获取)
+5. [用量追踪](#用量追踪)
+6. [提供商插件结构](#提供商插件结构)
+7. [集成要求](#集成要求)
+8. [API 端点](#api-端点)
+9. [配置](#配置)
+10. [在 PicoClaw 中创建新提供商](#在-picoclaw-中创建新提供商)
+
+---
+
+## 认证流程
+
+### 1. 带 PKCE 的 OAuth 2.0
+
+Antigravity 使用 **OAuth 2.0 with PKCE(Proof Key for Code Exchange)** 进行安全认证:
+
+```
+┌─────────────┐ ┌─────────────────┐
+│ Client │ ───(1) Generate PKCE Pair────────> │ │
+│ │ ───(2) Open Auth URL─────────────> │ Google OAuth │
+│ │ │ Server │
+│ │ <──(3) Redirect with Code───────── │ │
+│ │ └─────────────────┘
+│ │ ───(4) Exchange Code for Tokens──> │ Token URL │
+│ │ │ │
+│ │ <──(5) Access + Refresh Tokens──── │ │
+└─────────────┘ └─────────────────┘
+```
+
+### 2. 详细步骤
+
+#### 步骤 1:生成 PKCE 参数
+```typescript
+function generatePkce(): { verifier: string; challenge: string } {
+ const verifier = randomBytes(32).toString("hex");
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
+ return { verifier, challenge };
+}
+```
+
+#### 步骤 2:构建授权 URL
+```typescript
+const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
+const REDIRECT_URI = "http://localhost:51121/oauth-callback";
+
+function buildAuthUrl(params: { challenge: string; state: string }): string {
+ const url = new URL(AUTH_URL);
+ url.searchParams.set("client_id", CLIENT_ID);
+ url.searchParams.set("response_type", "code");
+ url.searchParams.set("redirect_uri", REDIRECT_URI);
+ url.searchParams.set("scope", SCOPES.join(" "));
+ url.searchParams.set("code_challenge", params.challenge);
+ url.searchParams.set("code_challenge_method", "S256");
+ url.searchParams.set("state", params.state);
+ url.searchParams.set("access_type", "offline");
+ url.searchParams.set("prompt", "consent");
+ return url.toString();
+}
+```
+
+**所需权限范围:**
+```typescript
+const SCOPES = [
+ "https://www.googleapis.com/auth/cloud-platform",
+ "https://www.googleapis.com/auth/userinfo.email",
+ "https://www.googleapis.com/auth/userinfo.profile",
+ "https://www.googleapis.com/auth/cclog",
+ "https://www.googleapis.com/auth/experimentsandconfigs",
+];
+```
+
+#### 步骤 3:处理 OAuth 回调
+
+**自动模式(本地开发):**
+- 在端口 51121 上启动本地 HTTP 服务器
+- 等待来自 Google 的重定向
+- 从查询参数中提取授权码
+
+**手动模式(远程/无头环境):**
+- 向用户显示授权 URL
+- 用户在浏览器中完成认证
+- 用户将完整的重定向 URL 粘贴回终端
+- 从粘贴的 URL 中解析授权码
+
+#### 步骤 4:用授权码交换令牌
+```typescript
+const TOKEN_URL = "https://oauth2.googleapis.com/token";
+
+async function exchangeCode(params: {
+ code: string;
+ verifier: string;
+}): Promise<{ access: string; refresh: string; expires: number }> {
+ const response = await fetch(TOKEN_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ client_id: CLIENT_ID,
+ client_secret: CLIENT_SECRET,
+ code: params.code,
+ grant_type: "authorization_code",
+ redirect_uri: REDIRECT_URI,
+ code_verifier: params.verifier,
+ }),
+ });
+
+ const data = await response.json();
+
+ return {
+ access: data.access_token,
+ refresh: data.refresh_token,
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer
+ };
+}
+```
+
+#### 步骤 5:获取额外的用户数据
+
+**用户邮箱:**
+```typescript
+async function fetchUserEmail(accessToken: string): Promise {
+ const response = await fetch(
+ "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
+ { headers: { Authorization: `Bearer ${accessToken}` } }
+ );
+ const data = await response.json();
+ return data.email;
+}
+```
+
+**项目 ID(API 调用必需):**
+```typescript
+async function fetchProjectId(accessToken: string): Promise {
+ const headers = {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "google-api-nodejs-client/9.15.1",
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+ "Client-Metadata": JSON.stringify({
+ ideType: "IDE_UNSPECIFIED",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ }),
+ };
+
+ const response = await fetch(
+ "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ metadata: {
+ ideType: "IDE_UNSPECIFIED",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ },
+ }),
+ }
+ );
+
+ const data = await response.json();
+ return data.cloudaicompanionProject || "rising-fact-p41fc"; // 默认回退值
+}
+```
+
+---
+
+## OAuth 实现细节
+
+### 客户端凭据
+
+**重要:** 这些凭据在源代码中以 base64 编码存储,用于与 pi-ai 同步:
+
+```typescript
+const decode = (s: string) => Buffer.from(s, "base64").toString();
+
+const CLIENT_ID = decode(
+ "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ=="
+);
+const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
+```
+
+### OAuth 流程模式
+
+1. **自动流程**(有浏览器的本地机器):
+ - 自动打开浏览器
+ - 本地回调服务器捕获重定向
+ - 初始认证后无需用户交互
+
+2. **手动流程**(远程/无头/WSL2 环境):
+ - 显示 URL 供手动复制粘贴
+ - 用户在外部浏览器中完成认证
+ - 用户将完整的重定向 URL 粘贴回来
+
+```typescript
+function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
+ return isRemote || isWSL2Sync();
+}
+```
+
+---
+
+## 令牌管理
+
+### 认证配置文件结构
+
+```typescript
+type OAuthCredential = {
+ type: "oauth";
+ provider: "google-antigravity";
+ access: string; // 访问令牌
+ refresh: string; // 刷新令牌
+ expires: number; // 过期时间戳(毫秒,自 epoch 起)
+ email?: string; // 用户邮箱
+ projectId?: string; // Google Cloud 项目 ID
+};
+```
+
+### 令牌刷新
+
+凭据包含一个刷新令牌,可在当前访问令牌过期时用于获取新的访问令牌。过期时间设置了 5 分钟的缓冲区以防止竞态条件。
+
+---
+
+## 模型列表获取
+
+### 获取可用模型
+
+```typescript
+const BASE_URL = "https://cloudcode-pa.googleapis.com";
+
+async function fetchAvailableModels(
+ accessToken: string,
+ projectId: string
+): Promise {
+ const headers = {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "antigravity",
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+ };
+
+ const response = await fetch(
+ `${BASE_URL}/v1internal:fetchAvailableModels`,
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ project: projectId }),
+ }
+ );
+
+ const data = await response.json();
+
+ // 返回带有配额信息的模型
+ return Object.entries(data.models).map(([modelId, modelInfo]) => ({
+ id: modelId,
+ displayName: modelInfo.displayName,
+ quotaInfo: {
+ remainingFraction: modelInfo.quotaInfo?.remainingFraction,
+ resetTime: modelInfo.quotaInfo?.resetTime,
+ isExhausted: modelInfo.quotaInfo?.isExhausted,
+ },
+ }));
+}
+```
+
+### 响应格式
+
+```typescript
+type FetchAvailableModelsResponse = {
+ models?: Record;
+};
+```
+
+---
+
+## 用量追踪
+
+### 获取用量数据
+
+```typescript
+export async function fetchAntigravityUsage(
+ token: string,
+ timeoutMs: number
+): Promise {
+ // 1. 获取额度和计划信息
+ const loadCodeAssistRes = await fetch(
+ `${BASE_URL}/v1internal:loadCodeAssist`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ metadata: {
+ ideType: "ANTIGRAVITY",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ },
+ }),
+ }
+ );
+
+ // 提取额度信息
+ const { availablePromptCredits, planInfo, currentTier } = data;
+
+ // 2. 获取模型配额
+ const modelsRes = await fetch(
+ `${BASE_URL}/v1internal:fetchAvailableModels`,
+ {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}` },
+ body: JSON.stringify({ project: projectId }),
+ }
+ );
+
+ // 构建用量窗口
+ return {
+ provider: "google-antigravity",
+ displayName: "Google Antigravity",
+ windows: [
+ { label: "Credits", usedPercent: calculateUsedPercent(available, monthly) },
+ // 各模型配额...
+ ],
+ plan: currentTier?.name || planType,
+ };
+}
+```
+
+### 用量响应结构
+
+```typescript
+type ProviderUsageSnapshot = {
+ provider: "google-antigravity";
+ displayName: string;
+ windows: UsageWindow[];
+ plan?: string;
+ error?: string;
+};
+
+type UsageWindow = {
+ label: string; // "Credits" 或模型 ID
+ usedPercent: number; // 0-100
+ resetAt?: number; // 配额重置的时间戳
+};
+```
+
+---
+
+## 提供商插件结构
+
+### 插件定义
+
+```typescript
+const antigravityPlugin = {
+ id: "google-antigravity-auth",
+ name: "Google Antigravity Auth",
+ description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
+ configSchema: emptyPluginConfigSchema(),
+
+ register(api: PicoClawPluginApi) {
+ api.registerProvider({
+ id: "google-antigravity",
+ label: "Google Antigravity",
+ docsPath: "/providers/models",
+ aliases: ["antigravity"],
+
+ auth: [
+ {
+ id: "oauth",
+ label: "Google OAuth",
+ hint: "PKCE + localhost callback",
+ kind: "oauth",
+ run: async (ctx: ProviderAuthContext) => {
+ // OAuth 实现在此处
+ },
+ },
+ ],
+ });
+ },
+};
+```
+
+### ProviderAuthContext
+
+```typescript
+type ProviderAuthContext = {
+ config: PicoClawConfig;
+ agentDir?: string;
+ workspaceDir?: string;
+ prompter: WizardPrompter; // UI 提示/通知
+ runtime: RuntimeEnv; // 日志等
+ isRemote: boolean; // 是否在远程运行
+ openUrl: (url: string) => Promise; // 浏览器打开器
+ oauth: {
+ createVpsAwareHandlers: Function;
+ };
+};
+```
+
+### ProviderAuthResult
+
+```typescript
+type ProviderAuthResult = {
+ profiles: Array<{
+ profileId: string;
+ credential: AuthProfileCredential;
+ }>;
+ configPatch?: Partial;
+ defaultModel?: string;
+ notes?: string[];
+};
+```
+
+---
+
+## 集成要求
+
+### 1. 所需环境/依赖
+
+- Go ≥ 1.25
+- PicoClaw 代码库(`pkg/providers/` 和 `pkg/auth/`)
+- `crypto` 和 `net/http` 标准库包
+
+### 2. API 调用所需的请求头
+
+```typescript
+const REQUIRED_HEADERS = {
+ "Authorization": `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "antigravity", // 或 "google-api-nodejs-client/9.15.1"
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+};
+
+// 对于 loadCodeAssist 调用,还需包含:
+const CLIENT_METADATA = {
+ ideType: "ANTIGRAVITY", // 或 "IDE_UNSPECIFIED"
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+};
+```
+
+### 3. 模型 Schema 清理
+
+Antigravity 使用兼容 Gemini 的模型,因此工具 schema 必须进行清理:
+
+```typescript
+const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
+ "patternProperties",
+ "additionalProperties",
+ "$schema",
+ "$id",
+ "$ref",
+ "$defs",
+ "definitions",
+ "examples",
+ "minLength",
+ "maxLength",
+ "minimum",
+ "maximum",
+ "multipleOf",
+ "pattern",
+ "format",
+ "minItems",
+ "maxItems",
+ "uniqueItems",
+ "minProperties",
+ "maxProperties",
+]);
+
+// 发送前清理 schema
+function cleanToolSchemaForGemini(schema: Record): unknown {
+ // 移除不支持的关键字
+ // 确保顶层有 type: "object"
+ // 展平 anyOf/oneOf 联合类型
+}
+```
+
+### 4. 思维块处理(Claude 模型)
+
+对于 Antigravity 的 Claude 模型,思维块需要特殊处理:
+
+```typescript
+const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/;
+
+export function sanitizeAntigravityThinkingBlocks(
+ messages: AgentMessage[]
+): AgentMessage[] {
+ // 验证思维签名
+ // 规范化签名字段
+ // 丢弃未签名的思维块
+}
+```
+
+---
+
+## API 端点
+
+### 认证端点
+
+| 端点 | 方法 | 用途 |
+|------|------|------|
+| `https://accounts.google.com/o/oauth2/v2/auth` | GET | OAuth 授权 |
+| `https://oauth2.googleapis.com/token` | POST | 令牌交换 |
+| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | 用户信息(邮箱) |
+
+### Cloud Code Assist 端点
+
+| 端点 | 方法 | 用途 |
+|------|------|------|
+| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | 加载项目信息、额度、计划 |
+| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | 列出可用模型及配额 |
+| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | 聊天流式端点 |
+
+**API 请求格式(聊天):**
+`v1internal:streamGenerateContent` 端点期望一个包装标准 Gemini 请求的信封格式:
+
+```json
+{
+ "project": "your-project-id",
+ "model": "model-id",
+ "request": {
+ "contents": [...],
+ "systemInstruction": {...},
+ "generationConfig": {...},
+ "tools": [...]
+ },
+ "requestType": "agent",
+ "userAgent": "antigravity",
+ "requestId": "agent-timestamp-random"
+}
+```
+
+**API 响应格式(SSE):**
+每条 SSE 消息(`data: {...}`)被包装在 `response` 字段中:
+
+```json
+{
+ "response": {
+ "candidates": [...],
+ "usageMetadata": {...},
+ "modelVersion": "...",
+ "responseId": "..."
+ },
+ "traceId": "...",
+ "metadata": {}
+}
+```
+
+---
+
+## 配置
+
+### config.json 配置
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gemini-flash",
+ "model": "antigravity/gemini-3-flash",
+ "auth_method": "oauth"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model_name": "gemini-flash"
+ }
+ }
+}
+```
+
+### 认证配置文件存储
+
+认证配置文件存储在 `~/.picoclaw/auth.json` 中:
+
+```json
+{
+ "credentials": {
+ "google-antigravity": {
+ "access_token": "ya29...",
+ "refresh_token": "1//...",
+ "expires_at": "2026-01-01T00:00:00Z",
+ "provider": "google-antigravity",
+ "auth_method": "oauth",
+ "email": "user@example.com",
+ "project_id": "my-project-id"
+ }
+ }
+}
+```
+
+---
+
+## 在 PicoClaw 中创建新提供商
+
+PicoClaw 提供商以 Go 包的形式实现,位于 `pkg/providers/` 下。要添加新提供商:
+
+### 分步实现
+
+#### 1. 创建提供商文件
+
+在 `pkg/providers/` 中创建新的 Go 文件:
+
+```
+pkg/providers/
+└── your_provider.go
+```
+
+#### 2. 实现 Provider 接口
+
+你的提供商必须实现 `pkg/providers/types.go` 中定义的 `Provider` 接口:
+
+```go
+package providers
+
+type YourProvider struct {
+ apiKey string
+ apiBase string
+}
+
+func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider {
+ if apiBase == "" {
+ apiBase = "https://api.your-provider.com/v1"
+ }
+ return &YourProvider{apiKey: apiKey, apiBase: apiBase}
+}
+
+func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error {
+ // 实现带流式传输的聊天补全
+}
+```
+
+#### 3. 在工厂中注册
+
+将你的提供商添加到 `pkg/providers/factory.go` 中的协议分支:
+
+```go
+case "your-provider":
+ return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil
+```
+
+#### 4. 添加默认配置(可选)
+
+在 `pkg/config/defaults.go` 中添加默认条目:
+
+```go
+{
+ ModelName: "your-model",
+ Model: "your-provider/model-name",
+ APIKey: "",
+},
+```
+
+#### 5. 添加认证支持(可选)
+
+如果你的提供商需要 OAuth 或特殊认证,在 `cmd/picoclaw/internal/auth/helpers.go` 中添加分支:
+
+```go
+case "your-provider":
+ authLoginYourProvider()
+```
+
+#### 6. 通过 `config.json` 配置
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "your-model",
+ "model": "your-provider/model-name",
+ "api_key": "your-api-key",
+ "api_base": "https://api.your-provider.com/v1"
+ }
+ ]
+}
+```
+
+---
+
+## 测试你的实现
+
+### CLI 命令
+
+```bash
+# 使用提供商进行认证
+picoclaw auth login --provider your-provider
+
+# 列出模型(用于 Antigravity)
+picoclaw auth models
+
+# 启动网关
+picoclaw gateway
+
+# 使用指定模型运行代理
+picoclaw agent -m "Hello" --model your-model
+```
+
+### 测试用环境变量
+
+```bash
+# 覆盖默认模型
+export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model
+
+# 覆盖提供商设置
+export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]'
+```
+
+---
+
+## 参考资料
+
+- **源文件:**
+ - `pkg/providers/antigravity_provider.go` - Antigravity 提供商实现
+ - `pkg/auth/oauth.go` - OAuth 流程实现
+ - `pkg/auth/store.go` - 认证凭据存储(`~/.picoclaw/auth.json`)
+ - `pkg/providers/factory.go` - 提供商工厂和协议路由
+ - `pkg/providers/types.go` - 提供商接口定义
+ - `cmd/picoclaw/internal/auth/helpers.go` - 认证 CLI 命令
+
+- **文档:**
+ - `docs/ANTIGRAVITY_USAGE.md` - Antigravity 使用指南
+ - `docs/migration/model-list-migration.md` - 迁移指南
+
+---
+
+## 注意事项
+
+1. **Google Cloud 项目:** Antigravity 要求在你的 Google Cloud 项目上启用 Gemini for Google Cloud
+2. **配额:** 使用 Google Cloud 项目配额(非独立计费)
+3. **模型访问:** 可用模型取决于你的 Google Cloud 项目配置
+4. **思维块:** 通过 Antigravity 使用的 Claude 模型需要对带签名的思维块进行特殊处理
+5. **Schema 清理:** 工具 schema 必须清理以移除不支持的 JSON Schema 关键字
+
+---
+
+---
+
+## 常见错误处理
+
+### 1. 速率限制(HTTP 429)
+
+当项目/模型配额耗尽时,Antigravity 会返回 429 错误。错误响应通常在 `details` 字段中包含 `quotaResetDelay`。
+
+**429 错误示例:**
+```json
+{
+ "error": {
+ "code": 429,
+ "message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.",
+ "status": "RESOURCE_EXHAUSTED",
+ "details": [
+ {
+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
+ "metadata": {
+ "quotaResetDelay": "4h30m28.060903746s"
+ }
+ }
+ ]
+ }
+}
+```
+
+### 2. 空响应(受限模型)
+
+某些模型可能出现在可用模型列表中,但返回空响应(200 OK 但 SSE 流为空)。这通常发生在当前项目没有权限使用的预览版或受限模型上。
+
+**处理方式:** 将空响应视为错误,通知用户该模型可能对其项目受限或无效。
+
+---
+
+## 故障排除
+
+### "Token expired"(令牌已过期)
+- 刷新 OAuth 令牌:`picoclaw auth login --provider antigravity`
+
+### "Gemini for Google Cloud is not enabled"(Gemini for Google Cloud 未启用)
+- 在 Google Cloud Console 中启用该 API
+
+### "Project not found"(项目未找到)
+- 确保你的 Google Cloud 项目已启用必要的 API
+- 检查认证过程中项目 ID 是否正确获取
+
+### 模型未出现在列表中
+- 验证 OAuth 认证是否成功完成
+- 检查认证配置文件存储:`~/.picoclaw/auth.json`
+- 重新运行 `picoclaw auth login --provider antigravity`
diff --git a/docs/zh/ANTIGRAVITY_USAGE.md b/docs/zh/ANTIGRAVITY_USAGE.md
new file mode 100644
index 000000000..2218618a9
--- /dev/null
+++ b/docs/zh/ANTIGRAVITY_USAGE.md
@@ -0,0 +1,72 @@
+> 返回 [README](../../README.zh.md)
+
+# 在 PicoClaw 中使用 Antigravity 提供商
+
+本指南介绍如何在 PicoClaw 中设置和使用 **Antigravity**(Google Cloud Code Assist)提供商。
+
+## 前提条件
+
+1. 一个 Google 账户。
+2. 已启用 Google Cloud Code Assist(通常通过"Gemini for Google Cloud"引导流程获取)。
+
+## 1. 身份验证
+
+要使用 Antigravity 进行身份验证,请运行以下命令:
+
+```bash
+picoclaw auth login --provider antigravity
+```
+
+### 手动验证(无界面/VPS 环境)
+如果你在服务器(Coolify/Docker)上运行且无法访问 `localhost`,请按照以下步骤操作:
+1. 运行上述命令。
+2. 复制提供的 URL 并在本地浏览器中打开。
+3. 完成登录。
+4. 浏览器将重定向到 `localhost:51121` URL(页面将无法加载)。
+5. **从浏览器地址栏复制该最终 URL**。
+6. **将其粘贴回 PicoClaw 正在等待的终端中**。
+
+PicoClaw 将自动提取授权码并完成流程。
+
+## 2. 管理模型
+
+### 列出可用模型
+查看你的项目可以访问哪些模型并检查其配额:
+
+```bash
+picoclaw auth models
+```
+
+### 切换模型
+你可以在 `~/.picoclaw/config.json` 中更改默认模型,或通过 CLI 覆盖:
+
+```bash
+# 为单个命令覆盖
+picoclaw agent -m "Hello" --model claude-opus-4-6-thinking
+```
+
+## 3. 实际使用(Coolify/Docker)
+
+如果你通过 Coolify 或 Docker 部署,请按照以下步骤进行测试:
+
+1. **环境变量**:
+ * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash`
+2. **身份验证持久化**:
+ 如果你已在本地登录,可以将凭据复制到服务器:
+ ```bash
+ scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/
+ ```
+ *或者*,如果你有终端访问权限,可以在服务器上运行一次 `auth login` 命令。
+
+## 4. 故障排除
+
+* **空响应**:如果模型返回空回复,可能是该模型在你的项目中受到限制。请尝试 `gemini-3-flash` 或 `claude-opus-4-6-thinking`。
+* **429 速率限制**:Antigravity 有严格的配额限制。如果触发限制,PicoClaw 将在错误消息中显示"重置时间"。
+* **404 未找到**:确保你使用的是 `picoclaw auth models` 列表中的模型 ID。请使用短 ID(例如 `gemini-3-flash`),而非完整路径。
+
+## 5. 可用模型总结
+
+根据测试,以下模型最为可靠:
+* `gemini-3-flash`(快速,高可用性)
+* `gemini-2.5-flash-lite`(轻量级)
+* `claude-opus-4-6-thinking`(强大,包含推理能力)
diff --git a/docs/zh/chat-apps.md b/docs/zh/chat-apps.md
index f082f7cf0..a0206a7d6 100644
--- a/docs/zh/chat-apps.md
+++ b/docs/zh/chat-apps.md
@@ -14,7 +14,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
| -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](../channels/telegram/README.zh.md) |
| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](../channels/discord/README.zh.md) |
-| **WhatsApp** | ⭐ 简单 | 原生 (QR 扫码) 或 Bridge URL | [查看文档](../channels/whatsapp/README.zh.md) |
+| **WhatsApp** | ⭐ 简单 | 原生 (QR 扫码) 或 Bridge URL | [查看文档](#whatsapp) |
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](../channels/slack/README.zh.md) |
| **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](../channels/matrix/README.zh.md) |
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](../channels/qq/README.zh.md) |
@@ -207,12 +207,13 @@ picoclaw gateway
QQ
-**1. 创建 Bot**
+**快速设置(推荐)**
-- 前往 [QQ 开放平台](https://q.qq.com/#)
-- 创建应用 → 获取 **AppID** 和 **AppSecret**
+QQ 开放平台提供了一键创建 OpenClaw 兼容机器人的页面:
-**2. 配置**
+1. 打开 [QQ 机器人快速创建](https://q.qq.com/qqbot/openclaw/index.html),扫码登录
+2. 机器人自动创建 — 复制 **App ID** 和 **App Secret**
+3. 配置 PicoClaw:
```json
{
@@ -227,13 +228,20 @@ picoclaw gateway
}
```
-> `allow_from` 留空表示允许所有用户,或指定 QQ 号限制访问。
+4. 运行 `picoclaw gateway`,打开 QQ 与机器人聊天
-**3. 运行**
+> App Secret 仅显示一次,请立即保存 — 再次查看将强制重置。
+>
+> 通过快速创建页面创建的机器人初始仅限创建者使用,不支持群聊。如需启用群聊访问,请在 [QQ 开放平台](https://q.qq.com/) 配置沙箱模式。
-```bash
-picoclaw gateway
-```
+**手动设置**
+
+如果你更喜欢手动创建机器人:
+
+* 登录 [QQ 开放平台](https://q.qq.com/) 注册成为开发者
+* 创建 QQ 机器人 — 自定义头像和名称
+* 从机器人设置中复制 **App ID** 和 **App Secret**
+* 按上述方式配置并运行 `picoclaw gateway`
@@ -242,9 +250,10 @@ picoclaw gateway
**1. 创建 Slack App**
-* 前往 [Slack API](https://api.slack.com/apps) 创建应用
-* 启用 **Socket Mode**
-* 获取 **Bot Token** 和 **App-Level Token**
+* 前往 [Slack API](https://api.slack.com/apps) 创建新应用
+* 在 **OAuth & Permissions** 中添加 Bot 权限范围:`chat:write`、`app_mentions:read`、`im:history`、`im:read`、`im:write`
+* 将应用安装到你的工作区
+* 复制 **Bot Token**(`xoxb-...`)和 **App-Level Token**(`xapp-...`,启用 Socket Mode 后获取)
**2. 配置**
@@ -253,8 +262,8 @@ picoclaw gateway
"channels": {
"slack": {
"enabled": true,
- "bot_token": "xoxb-YOUR_BOT_TOKEN",
- "app_token": "xapp-YOUR_APP_TOKEN",
+ "bot_token": "xoxb-YOUR-BOT-TOKEN",
+ "app_token": "xapp-YOUR-APP-TOKEN",
"allow_from": []
}
}
@@ -280,21 +289,26 @@ picoclaw gateway
"irc": {
"enabled": true,
"server": "irc.libera.chat:6697",
+ "tls": true,
"nick": "picoclaw-bot",
- "use_tls": true,
- "channels_to_join": ["#your-channel"],
+ "channels": ["#your-channel"],
+ "password": "",
"allow_from": []
}
}
}
```
+可选:`nickserv_password` 用于 NickServ 认证,`sasl_user`/`sasl_password` 用于 SASL 认证。
+
**2. 运行**
```bash
picoclaw gateway
```
+Bot 将连接到 IRC 服务器并加入指定的频道。
+
@@ -382,11 +396,14 @@ picoclaw gateway
飞书 (Feishu)
+PicoClaw 通过 WebSocket/SDK 模式连接飞书 — 无需公网 Webhook URL 或回调服务器。
+
**1. 创建应用**
-* 前往 [飞书开放平台](https://open.feishu.cn/)
-* 创建企业自建应用
-* 获取 **App ID** 和 **App Secret**
+* 前往 [飞书开放平台](https://open.feishu.cn/) 创建应用
+* 在应用设置中启用 **机器人** 能力
+* 创建版本并发布应用(应用必须发布后才能生效)
+* 复制 **App ID**(以 `cli_` 开头)和 **App Secret**
**2. 配置**
@@ -396,21 +413,25 @@ picoclaw gateway
"feishu": {
"enabled": true,
"app_id": "cli_xxx",
- "app_secret": "xxx",
- "encrypt_key": "",
- "verification_token": "",
+ "app_secret": "YOUR_APP_SECRET",
"allow_from": []
}
}
}
```
-**3. 运行**
+可选:`encrypt_key` 和 `verification_token` 用于事件加密(生产环境推荐)。
+
+**3. 运行并聊天**
```bash
picoclaw gateway
```
+打开飞书,搜索你的机器人名称即可开始聊天。也可以将机器人添加到群组 — 使用 `group_trigger.mention_only: true` 设置为仅在 @提及时回复。
+
+完整选项请参考 [飞书渠道配置指南](../channels/feishu/README.zh.md)。
+
@@ -496,7 +517,7 @@ picoclaw gateway
**1. 创建 AI Bot**
* 企业微信管理后台 → 应用管理 → AI Bot
-* 在 AI Bot 设置中配置回调 URL:`http://your-server:18791/webhook/wecom-aibot`
+* 在 AI Bot 设置中配置回调 URL:`http://your-server:18790/webhook/wecom-aibot`
* 复制 **Token** 并点击"随机生成" **EncodingAESKey**
**2. 配置**
@@ -528,24 +549,36 @@ picoclaw gateway
-OneBot
+OneBot(通过 OneBot 协议连接 QQ)
-**1. 配置**
+OneBot 是 QQ 机器人的开放协议。PicoClaw 通过 WebSocket 连接任何 OneBot v11 兼容实现(如 [Lagrange](https://github.com/LagrangeDev/Lagrange.Core)、[NapCat](https://github.com/NapNeko/NapCatQQ))。
-兼容 NapCat / Go-CQHTTP 等 OneBot 实现。
+**1. 设置 OneBot 实现**
+
+安装并运行 OneBot v11 兼容的 QQ 机器人框架,启用其 WebSocket 服务器。
+
+**2. 配置**
```json
{
"channels": {
"onebot": {
"enabled": true,
+ "ws_url": "ws://127.0.0.1:8080",
+ "access_token": "",
"allow_from": []
}
}
}
```
-**2. 运行**
+| 字段 | 说明 |
+|------|------|
+| `ws_url` | OneBot 实现的 WebSocket URL |
+| `access_token` | 认证用的访问令牌(如果在 OneBot 中配置了的话) |
+| `reconnect_interval` | 重连间隔(秒)(默认:5) |
+
+**3. 运行**
```bash
picoclaw gateway
diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md
index a2bf8fce2..68fb1fd1a 100644
--- a/docs/zh/configuration.md
+++ b/docs/zh/configuration.md
@@ -57,7 +57,7 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
1. `~/.picoclaw/workspace/skills`(工作区)
2. `~/.picoclaw/skills`(全局)
-3. `/skills`(内置)
+3. `<构建时嵌入路径>/skills`(内置)
在高级/测试场景下,可通过以下环境变量覆盖内置技能目录:
diff --git a/docs/zh/credential_encryption.md b/docs/zh/credential_encryption.md
new file mode 100644
index 000000000..2105e4307
--- /dev/null
+++ b/docs/zh/credential_encryption.md
@@ -0,0 +1,158 @@
+> 返回 [README](../../README.zh.md)
+
+# 凭据加密
+
+PicoClaw 支持对 `model_list` 配置条目中的 `api_key` 值进行加密。
+加密后的密钥以 `enc://` 字符串形式存储,并在启动时自动解密。
+
+---
+
+## 快速开始
+
+**1. 设置密码短语**
+
+```bash
+export PICOCLAW_KEY_PASSPHRASE="your-passphrase"
+```
+
+**2. 加密 API 密钥**
+
+运行 `picoclaw onboard` — 它会提示你输入密码短语并生成 SSH 密钥,
+然后在下一次 `SaveConfig` 调用时自动重新加密配置中所有明文 `api_key` 条目。生成的 `enc://` 值如下所示:
+
+```
+enc://AAAA...base64...
+```
+
+**3. 将输出粘贴到你的配置中**
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-4o",
+ "model": "openai/gpt-4o",
+ "api_key": "enc://AAAA...base64...",
+ "api_base": "https://api.openai.com/v1"
+ }
+ ]
+}
+```
+
+---
+
+## 支持的 `api_key` 格式
+
+| 格式 | 示例 | 行为 |
+|------|------|------|
+| 明文 | `sk-abc123` | 直接使用 |
+| 文件引用 | `file://openai.key` | 从配置文件所在目录读取内容 |
+| 加密 | `enc://` | 启动时使用 `PICOCLAW_KEY_PASSPHRASE` 解密 |
+| 空值 | `""` | 原样传递(用于 `auth_method: oauth`) |
+
+---
+
+## 加密设计
+
+### 密钥派生
+
+加密使用 **HKDF-SHA256**,并以 SSH 私钥作为第二因子。
+
+```
+sshHash = SHA256(ssh_private_key_file_bytes)
+ikm = HMAC-SHA256(key=sshHash, message=passphrase)
+aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes)
+```
+
+### 加密
+
+```
+AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key)
+```
+
+### 传输格式
+
+```
+enc://
+```
+
+| 字段 | 大小 | 描述 |
+|------|------|------|
+| `salt` | 16 字节 | 每次加密随机生成;输入 HKDF |
+| `nonce` | 12 字节 | 每次加密随机生成;AES-GCM IV |
+| `ciphertext` | 可变 | AES-256-GCM 密文 + 16 字节认证标签 |
+
+GCM 认证标签会自动附加到密文之后。任何篡改都会导致解密失败并报错,而不是返回损坏的明文。
+
+### 性能
+
+| 操作 | 耗时 (ARM Cortex-A) |
+|------|---------------------|
+| 密钥派生 (HKDF) | < 1 ms |
+| AES-256-GCM 解密 | < 1 ms |
+| **启动总开销** | **每个密钥 < 2 ms** |
+
+---
+
+## 使用 SSH 密钥的双因子安全
+
+当提供 SSH 私钥时,破解加密需要**同时具备**:
+
+1. **密码短语** (`PICOCLAW_KEY_PASSPHRASE`)
+2. **SSH 私钥文件**
+
+这意味着仅泄露配置文件不足以恢复 API 密钥,即使密码短语较弱也是如此。SSH 密钥贡献 256 位熵(Ed25519),与密码短语强度无关。
+
+### 威胁模型
+
+| 攻击者拥有 | 能否解密? |
+|------------|-----------|
+| 仅配置文件 | 否 — 需要密码短语 + SSH 密钥 |
+| 仅 SSH 密钥 | 否 — 需要密码短语 |
+| 仅密码短语 | 否 — 需要 SSH 密钥 |
+| 配置文件 + SSH 密钥 + 密码短语 | 是 — 完全泄露 |
+
+---
+
+## 环境变量
+
+| 变量 | 是否必需 | 描述 |
+|------|----------|------|
+| `PICOCLAW_KEY_PASSPHRASE` | 是(用于 `enc://`) | 用于密钥派生的密码短语 |
+| `PICOCLAW_SSH_KEY_PATH` | 否 | SSH 私钥路径。如未设置,自动从 `~/.ssh/picoclaw_ed25519.key` 检测 |
+
+### SSH 密钥自动检测
+
+如果未设置 `PICOCLAW_SSH_KEY_PATH`,PicoClaw 会查找专用密钥:
+
+```
+~/.ssh/picoclaw_ed25519.key
+```
+
+此专用文件避免与用户现有的 SSH 密钥冲突。
+运行 `picoclaw onboard` 可自动生成该密钥。
+
+`os.UserHomeDir()` 用于跨平台主目录解析(在 Windows 上读取 `USERPROFILE`,在 Unix/macOS 上读取 `HOME`)。
+
+> **注意:** SSH 密钥文件是凭据加密的必要条件。如果未找到密钥且未设置 `PICOCLAW_SSH_KEY_PATH`,加密/解密将失败。运行 `picoclaw onboard` 可自动生成密钥。
+
+---
+
+## 迁移
+
+由于唯一的密钥材料是 `PICOCLAW_KEY_PASSPHRASE` 和 SSH 私钥文件,迁移非常简单:
+
+1. 将配置文件复制到新机器。
+2. 将 `PICOCLAW_KEY_PASSPHRASE` 设置为相同的值。
+3. 将 SSH 私钥文件复制到相同路径(或将 `PICOCLAW_SSH_KEY_PATH` 设置为新位置)。
+
+无需重新加密。
+
+---
+
+## 安全注意事项
+
+- **密码短语和 SSH 密钥都是必需的。** SSH 密钥作为第二因子 — 没有它,加密/解密将失败。如果密钥不存在,运行 `picoclaw onboard` 生成。
+- **SSH 密钥在运行时为只读。** PicoClaw 不会写入或修改 SSH 密钥文件。
+- **仍然支持明文密钥。** 不使用 `enc://` 的现有配置不受影响。
+- **`enc://` 格式通过版本控制**,通过 HKDF `info` 字段(`picoclaw-credential-v1`)实现,允许未来升级算法而不破坏现有加密值。
diff --git a/docs/zh/debug.md b/docs/zh/debug.md
new file mode 100644
index 000000000..e7f20d777
--- /dev/null
+++ b/docs/zh/debug.md
@@ -0,0 +1,36 @@
+# 调试 PicoClaw
+
+> 返回 [README](../../README.zh.md)
+
+PicoClaw 在处理每一个请求时,都会在后台执行多个复杂的交互操作——从消息路由和复杂度评估,到工具执行和模型故障适配。能够准确地看到正在发生什么至关重要,这不仅有助于排查潜在问题,也有助于真正理解代理的运作方式。
+
+## 以调试模式启动 PicoClaw
+
+要获取代理运行的详细信息(LLM 请求、工具调用、消息路由),可以使用调试标志启动 PicoClaw 网关:
+
+```bash
+picoclaw gateway --debug
+# or
+picoclaw gateway -d
+```
+
+在此模式下,系统会对日志进行详细格式化,并显示系统提示词和工具执行结果的预览。
+
+## 禁用日志截断(完整日志)
+
+默认情况下,PicoClaw 会在调试日志中截断过长的字符串(例如*系统提示词*或大型 JSON 输出结果),以保持控制台的可读性。
+
+如果你需要检查某个命令的完整输出,或发送给 LLM 模型的确切载荷,可以使用 `--no-truncate` 标志。
+
+**注意:** 此标志*仅*在与 `--debug` 模式组合使用时有效。
+
+```bash
+picoclaw gateway --debug --no-truncate
+
+```
+
+当此标志激活时,全局截断功能将被禁用。这在以下场景中非常有用:
+
+* 验证发送给提供商的消息的确切语法。
+* 读取 `exec`、`web_fetch` 或 `read_file` 等工具的完整输出。
+* 调试保存在内存中的会话历史。
diff --git a/docs/zh/docker.md b/docs/zh/docker.md
index d2e582d12..10bc46544 100644
--- a/docs/zh/docker.md
+++ b/docs/zh/docker.md
@@ -12,6 +12,7 @@ git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
# 2. 首次运行 — 自动生成 docker/data/config.json 后退出
+# (仅在 config.json 和 workspace/ 都不存在时触发)
docker compose -f docker/docker-compose.yml --profile gateway up
# 容器打印 "First-run setup complete." 后自动停止
diff --git a/docs/zh/hardware-compatibility.md b/docs/zh/hardware-compatibility.md
new file mode 100644
index 000000000..66bd08072
--- /dev/null
+++ b/docs/zh/hardware-compatibility.md
@@ -0,0 +1,152 @@
+> 返回 [README](../../README.zh.md)
+
+# 🖥️ PicoClaw 硬件兼容性列表
+
+PicoClaw 几乎可以在任何 Linux 设备上运行。本页面记录了已验证的芯片、产品和开发板。
+
+**你的硬件不在列表中?** 提交 PR 来添加它!欢迎硬件厂商贡献和联合推广。
+
+---
+
+## 1. 已验证的芯片支持
+
+### x86
+
+| 厂商 | 芯片 | 备注 |
+|------|------|------|
+| Intel | Any x86 CPU (i386+) | 所有桌面/服务器/笔记本处理器 |
+| AMD | Any x86 CPU | 所有桌面/服务器/笔记本处理器 |
+
+### ARM
+
+| 子架构 | 典型芯片 | 备注 |
+|--------|----------|------|
+| ARMv6 | [BCM2835](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2835) (Raspberry Pi 1/Zero) | 单核 ARM1176JZF-S |
+| ARMv7 | [Allwinner V3s](https://linux-sunxi.org/V3s) | 单核 Cortex-A7,用于 LicheePi Zero |
+| ARM64 | [Allwinner H618](https://linux-sunxi.org/H618) | 四核 Cortex-A53,用于 Orange Pi Zero 3 |
+| ARM64 | [BCM2711](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2711) (Raspberry Pi 4) | 四核 Cortex-A72 |
+| ARM64 | [BCM2712](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2712) (Raspberry Pi 5) | 四核 Cortex-A76 |
+| ARM64 | [AX630C](https://www.axera-tech.com/) (爱芯元智) | 双核 Cortex-A53 + NPU,用于 NanoKVM-Pro / MaixCAM2 |
+
+### RISC-V (riscv64)
+
+| 厂商 | 芯片 | 核心 | 备注 |
+|------|------|------|------|
+| [SOPHGO (算能)](https://www.sophgo.com/) | SG2002 | C906 @ 1GHz | 256MB DDR3 片上内存,用于 LicheeRV-Nano / NanoKVM / MaixCAM |
+| [Allwinner (全志)](https://www.allwinnertech.com/) | V861 | Dual C907 | 128MB DDR3L 片上内存,1 TOPS NPU,4K AI 摄像头 SiP |
+| [Allwinner (全志)](https://www.allwinnertech.com/) | V881 | C907 | RISC-V AI 摄像头系列 |
+| [Arterytek (匠芯创)](https://www.arterytek.com/) | D213 | RISC-V | 用于 HaaS506-LD1 工业 RTU |
+| [SpacemiT (进迭)](https://www.spacemit.com/) | K1 | 8x X60 @ 1.8GHz | 用于 Milk-V Jupiter, BananaPi BPI-F3 |
+| [SpacemiT (进迭)](https://www.spacemit.com/) | K3 | 8x X100 @ 2.5GHz | 符合 RVA23 规范,1024 位 RVV,FP8 AI 推理 |
+| [Zhihe (知合)](https://www.zhihe-tech.com/) | A210 | High-perf RISC-V | 8 核,16MB L3 缓存,桌面级 |
+| [Canaan (嘉楠)](https://www.canaan-creative.com/) | K230 | Dual C908 @ 1.6GHz | 6 TOPS KPU,用于 CanMV-K230 |
+
+### MIPS
+
+| 厂商 | 芯片 | 备注 |
+|------|------|------|
+| MediaTek | [MT7620](https://www.mediatek.com/products/home-networking/mt7620) | MIPS24KEc @ 580MHz,用于许多 OpenWrt 路由器(如小米路由器 3G) |
+
+### LoongArch (loong64)
+
+| 厂商 | 芯片 | 备注 |
+|------|------|------|
+| [Loongson (龙芯)](https://www.loongson.cn/) | 3A5000 | 四核 LA464 @ 2.5GHz,桌面/工作站 |
+| [Loongson (龙芯)](https://www.loongson.cn/) | 3A6000 | 四核 4C/8T @ 2.5GHz,IPC 可与 Intel 第十代相媲美 |
+| [Loongson (龙芯)](https://www.loongson.cn/) | 2K1000LA | 双核 @ 1GHz,工业/物联网应用 |
+
+---
+
+## 2. 已验证的产品(按发布日期排列)
+
+已通过 PicoClaw 测试的消费产品、路由器和工业设备。
+
+| 年份 | 产品 | 架构 | SoC | 内存 | 类别 |
+|------|------|------|-----|------|------|
+| 2009 | Nokia N900 | ARM (A8) | OMAP3430 | 256MB | 智能手机 |
+| 2012 | Samsung Galaxy Note 10.1 (N8000) | ARM (A9) | Exynos 4412 | 2GB | 平板电脑 |
+| 2016 | Xiaomi Router 3G (小米路由器3G) | MIPS | MT7620 | 256MB | 路由器 (OpenWrt) |
+| 2018 | Phicomm N1 (斐讯N1) | ARM64 (A53) | S905D | 2GB | 电视盒子 / 家庭服务器 |
+| 2019 | Xiaomi AI Speaker (小爱音箱) | ARM64 (A53) | — | 256MB | 智能音箱 |
+| 2024 | [NanoKVM](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html) | RISC-V | SG2002 | 256MB | IP-KVM |
+| 2025 | HaaS506-LD1 | RISC-V | D213 | 128MB | 工业 RTU |
+| 2025 | [NanoKVM-Pro](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM_Pro/introduction.html) | ARM64 (A53) | AX630C | 1GB | 专业 IP-KVM |
+| 2026 | [MaixCAM2](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | ARM64 (A53) | AX630C | 1/4GB | 4K AI 摄像头 |
+
+---
+
+## 3. 已验证的开发板(按发布日期排列)
+
+| 年份 | 开发板 | 架构 | SoC | 内存 | 购买链接 |
+|------|--------|------|-----|------|----------|
+| 2012 | [Raspberry Pi 1 Model B](https://www.raspberrypi.com/products/) | ARMv6 | BCM2835 | 512MB | — |
+| 2015 | [Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) | ARMv7 (A7) | BCM2836 | 1GB | — |
+| 2015 | [Raspberry Pi Zero](https://www.raspberrypi.com/products/raspberry-pi-zero/) | ARMv6 | BCM2835 | 512MB | — |
+| 2016 | [Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) | ARM64 (A53) | BCM2837 | 1GB | — |
+| 2017 | [LicheePi Zero](https://wiki.sipeed.com/hardware/en/lichee/Zero/Zero.html) | ARMv7 (A7) | Allwinner V3s | 64MB | [Sipeed](https://sipeed.com/) |
+| 2019 | [Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) | ARM64 (A72) | BCM2711 | 1~8GB | [RPi](https://www.raspberrypi.com/) |
+| 2023 | [Raspberry Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/) | ARM64 (A76) | BCM2712 | 2~8GB | [RPi](https://www.raspberrypi.com/) |
+| 2024 | [LicheeRV-Nano](https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html) | RISC-V | SG2002 | 256MB | [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) |
+| 2024 | [MaixCAM-Pro](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | RISC-V | SG2002 | 256MB | [Sipeed](https://sipeed.com/) |
+| 2024 | [Milk-V Duo 64M](https://milkv.io/docs/duo/getting-started/duo) | RISC-V | CV1800B | 64MB | [Milk-V](https://milkv.io/) |
+| 2024 | [CanMV-K230](https://developer.canaan-creative.com/k230_canmv/en/main/) | RISC-V | K230 | 512MB | [Canaan](https://www.canaan-creative.com/) |
+
+---
+
+## 4. 同样适用于
+
+### Android 手机(通过 Termux)
+
+任何 ARM64 Android 手机(2015 年以后),1GB 以上内存。安装 [Termux](https://github.com/termux/termux-app),使用 `proot` 运行 PicoClaw。
+
+> 参见 [README:在旧 Android 手机上运行](../../README.zh.md#-run-on-old-android-phones) 获取设置说明。
+
+### 桌面 / 服务器 / 云
+
+| 平台 | 备注 |
+|------|------|
+| x86_64 Linux | 原生二进制文件,无依赖 |
+| x86_64 Windows | 原生二进制文件 |
+| macOS (Intel / Apple Silicon) | 原生二进制文件 |
+| Docker (any platform) | `docker compose` 一行命令,参见 [Docker 指南](docker.md) |
+| OpenWrt routers | MIPS/ARM 构建,需要 >32MB 可用内存 |
+| FreeBSD / NetBSD | 提供 x86_64 和 arm64 构建 |
+
+---
+
+## 5. 最低要求
+
+| 资源 | 最低要求 | 推荐配置 |
+|------|----------|----------|
+| 内存 | 10MB 可用 | 32MB 以上可用 |
+| 存储 | 20MB(二进制文件) | 50MB 以上(含工作区) |
+| CPU | 任意(单核 0.6GHz 以上) | — |
+| 操作系统 | Linux (kernel 3.x+) | Linux 5.x+ |
+| 网络 | 必需(用于 LLM API 调用) | 以太网或 WiFi |
+
+---
+
+## 6. 如何测试与贡献
+
+```bash
+# 1. 下载适合你架构的版本
+wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
+tar xzf picoclaw_Linux_arm64.tar.gz
+
+# 2. 初始化
+./picoclaw onboard
+
+# 3. 测试
+./picoclaw agent -m "Hello, what board am I running on?"
+```
+
+可用构建版本:`linux-amd64`, `linux-arm64`, `linux-arm`, `linux-riscv64`, `linux-loong64`, `linux-mipsle`
+
+### 添加你的硬件
+
+1. Fork 本仓库
+2. 将你的芯片/产品/开发板添加到相应的表格中
+3. 包含:名称、架构、SoC、内存、年份,以及可用的链接
+4. 提交 PR
+
+硬件厂商:想要添加官方支持或联合推广?请提交 issue 或通过 [Discord](https://discord.gg/V4sAZ9XWpN) 联系我们。
diff --git a/docs/zh/providers.md b/docs/zh/providers.md
index 5b7a4cc2a..9092e7dfe 100644
--- a/docs/zh/providers.md
+++ b/docs/zh/providers.md
@@ -93,7 +93,7 @@
],
"agents": {
"defaults": {
- "model": "gpt-5.4"
+ "model_name": "gpt-5.4"
}
}
}
@@ -266,7 +266,7 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l
],
"agents": {
"defaults": {
- "model": "glm-4.7"
+ "model_name": "glm-4.7"
}
}
}
@@ -298,7 +298,7 @@ PicoClaw 按协议族路由 Provider:
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
+ "model_name": "glm-4.7",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
@@ -328,12 +328,11 @@ picoclaw agent -m "你好"
{
"agents": {
"defaults": {
- "model": "anthropic/claude-opus-4-5"
+ "model_name": "anthropic/claude-opus-4-5"
}
},
"session": {
- "dm_scope": "per-channel-peer",
- "backlog_limit": 20
+ "dm_scope": "per-channel-peer"
},
"providers": {
"openrouter": {
diff --git a/docs/zh/spawn-tasks.md b/docs/zh/spawn-tasks.md
index c6721fceb..781462af2 100644
--- a/docs/zh/spawn-tasks.md
+++ b/docs/zh/spawn-tasks.md
@@ -2,13 +2,15 @@
> 返回 [README](../../README.zh.md)
-### 使用 Spawn 的异步任务
+PicoClaw 通过 `spawn` 工具支持**异步任务执行**。主要由 **Heartbeat(心跳)** 系统使用,在不阻塞主 Agent 循环的情况下运行耗时任务。
-对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一个 **子 Agent (subagent)**:
+## Heartbeat
+
+心跳系统会定期检查 `workspace/HEARTBEAT.md` 中的计划任务。首次运行时会自动生成默认模板,你可以自定义它来定义快速任务(内联处理)和长任务(通过 `spawn` 委派)。
+
+**`HEARTBEAT.md` 示例:**
```markdown
-# Periodic Tasks
-
## Quick Tasks (respond directly)
- Report current time
diff --git a/docs/zh/tools_configuration.md b/docs/zh/tools_configuration.md
index e10e3d26a..f13448952 100644
--- a/docs/zh/tools_configuration.md
+++ b/docs/zh/tools_configuration.md
@@ -43,11 +43,12 @@ Web 工具用于网页搜索和抓取。
### Brave
-| 配置项 | 类型 | 默认值 | 描述 |
-|---------------|--------|--------|--------------------|
-| `enabled` | bool | false | 启用 Brave 搜索 |
-| `api_key` | string | - | Brave Search API 密钥 |
-| `max_results` | int | 5 | 最大结果数 |
+| 配置项 | 类型 | 默认值 | 描述 |
+|---------------|----------|--------|------------------------------------------------|
+| `enabled` | bool | false | 启用 Brave 搜索 |
+| `api_key` | string | - | Brave Search API 密钥 |
+| `api_keys` | string[] | - | 多个 API 密钥轮换(优先于 `api_key`) |
+| `max_results` | int | 5 | 最大结果数 |
### DuckDuckGo
@@ -58,11 +59,46 @@ Web 工具用于网页搜索和抓取。
### Perplexity
-| 配置项 | 类型 | 默认值 | 描述 |
-|---------------|--------|--------|-----------------------|
-| `enabled` | bool | false | 启用 Perplexity 搜索 |
-| `api_key` | string | - | Perplexity API 密钥 |
-| `max_results` | int | 5 | 最大结果数 |
+| 配置项 | 类型 | 默认值 | 描述 |
+|---------------|----------|--------|------------------------------------------------|
+| `enabled` | bool | false | 启用 Perplexity 搜索 |
+| `api_key` | string | - | Perplexity API 密钥 |
+| `api_keys` | string[] | - | 多个 API 密钥轮换(优先于 `api_key`) |
+| `max_results` | int | 5 | 最大结果数 |
+
+### Tavily
+
+| 配置项 | 类型 | 默认值 | 描述 |
+|---------------|--------|--------|-----------------------------------|
+| `enabled` | bool | false | 启用 Tavily 搜索 |
+| `api_key` | string | - | Tavily API 密钥 |
+| `base_url` | string | - | 自定义 Tavily API 基础 URL |
+| `max_results` | int | 0 | 最大结果数(0 = 默认) |
+
+### SearXNG
+
+| 配置项 | 类型 | 默认值 | 描述 |
+|---------------|--------|--------------------------|-----------------------|
+| `enabled` | bool | false | 启用 SearXNG 搜索 |
+| `base_url` | string | `http://localhost:8888` | SearXNG 实例 URL |
+| `max_results` | int | 5 | 最大结果数 |
+
+### GLM Search
+
+| 配置项 | 类型 | 默认值 | 描述 |
+|-----------------|--------|------------------------------------------------------|-----------------------|
+| `enabled` | bool | false | 启用 GLM 搜索 |
+| `api_key` | string | - | GLM API 密钥 |
+| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | GLM Search API URL |
+| `search_engine` | string | `search_std` | 搜索引擎类型 |
+| `max_results` | int | 5 | 最大结果数 |
+
+### 其他 Web 设置
+
+| 配置项 | 类型 | 默认值 | 描述 |
+|--------------------------|----------|--------|-------------------------------------------------|
+| `prefer_native` | bool | true | 优先使用 provider 原生搜索而非配置的搜索引擎 |
+| `private_host_whitelist` | string[] | `[]` | 允许 Web 抓取的私有/内部主机白名单 |
## Exec 工具
@@ -154,6 +190,7 @@ Cron 工具用于调度周期性任务。
| 配置项 | 类型 | 默认值 | 描述 |
|------------------------|------|--------|-------------------------------------|
| `exec_timeout_minutes` | int | 5 | 执行超时时间(分钟),0 表示无限制 |
+| `allow_command` | bool | false | 允许 cron 任务执行 shell 命令 |
## MCP 工具
@@ -320,9 +357,27 @@ Skills 工具配置通过 ClawHub 等注册表进行技能发现和安装。
| `registries.clawhub.enabled` | bool | true | 启用 ClawHub 注册表 |
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub 基础 URL |
| `registries.clawhub.auth_token` | string | `""` | 可选的 Bearer 令牌,用于更高速率限制 |
-| `registries.clawhub.search_path` | string | `/api/v1/search` | 搜索 API 路径 |
-| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API 路径 |
-| `registries.clawhub.download_path` | string | `/api/v1/download` | 下载 API 路径 |
+| `registries.clawhub.search_path` | string | `""` | 搜索 API 路径 |
+| `registries.clawhub.skills_path` | string | `""` | Skills API 路径 |
+| `registries.clawhub.download_path` | string | `""` | 下载 API 路径 |
+| `registries.clawhub.timeout` | int | 0 | 请求超时时间(秒),0 = 默认 |
+| `registries.clawhub.max_zip_size` | int | 0 | 技能 zip 最大大小(字节),0 = 默认 |
+| `registries.clawhub.max_response_size` | int | 0 | API 响应最大大小(字节),0 = 默认 |
+
+### GitHub 集成
+
+| 配置项 | 类型 | 默认值 | 描述 |
+|------------------|--------|--------|-------------------------------|
+| `github.proxy` | string | `""` | GitHub API 请求的 HTTP 代理 |
+| `github.token` | string | `""` | GitHub 个人访问令牌 |
+
+### 搜索设置
+
+| 配置项 | 类型 | 默认值 | 描述 |
+|----------------------------|------|--------|--------------------------|
+| `max_concurrent_searches` | int | 2 | 最大并发技能搜索请求数 |
+| `search_cache.max_size` | int | 50 | 最大缓存搜索结果数 |
+| `search_cache.ttl_seconds` | int | 300 | 缓存 TTL(秒) |
### 配置示例
@@ -334,11 +389,17 @@ Skills 工具配置通过 ClawHub 等注册表进行技能发现和安装。
"clawhub": {
"enabled": true,
"base_url": "https://clawhub.ai",
- "auth_token": "",
- "search_path": "/api/v1/search",
- "skills_path": "/api/v1/skills",
- "download_path": "/api/v1/download"
+ "auth_token": ""
}
+ },
+ "github": {
+ "proxy": "",
+ "token": ""
+ },
+ "max_concurrent_searches": 2,
+ "search_cache": {
+ "max_size": 50,
+ "ttl_seconds": 300
}
}
}
diff --git a/docs/zh/troubleshooting.md b/docs/zh/troubleshooting.md
index a3329ee35..be4d4f5d7 100644
--- a/docs/zh/troubleshooting.md
+++ b/docs/zh/troubleshooting.md
@@ -16,7 +16,7 @@
**修复方法:** 在 `~/.picoclaw/config.json`(或你的配置路径)中:
-1. **agents.defaults.model** 必须匹配 `model_list` 中的某个 `model_name`(例如 `"openrouter-free"`)。
+1. **agents.defaults.model_name** 必须匹配 `model_list` 中的某个 `model_name`(例如 `"openrouter-free"`)。
2. 该条目的 **model** 必须是有效的 OpenRouter 模型 ID,例如:
- `"openrouter/free"` – 自动免费层
- `"google/gemini-2.0-flash-exp:free"`
@@ -28,7 +28,7 @@
{
"agents": {
"defaults": {
- "model": "openrouter-free"
+ "model_name": "openrouter-free"
}
},
"model_list": [
From 6148ccc52937fb12c1f943bc28a865dcca982dc1 Mon Sep 17 00:00:00 2001
From: BeaconCat <111232138+BeaconCat@users.noreply.github.com>
Date: Sat, 21 Mar 2026 14:36:51 +0800
Subject: [PATCH 42/44] docs(feishu): note that Feishu channel does not support
32-bit devices (#1851)
Co-authored-by: BeaconCat
---
docs/channels/feishu/README.fr.md | 4 ++++
docs/channels/feishu/README.ja.md | 4 ++++
docs/channels/feishu/README.md | 4 ++++
docs/channels/feishu/README.pt-br.md | 4 ++++
docs/channels/feishu/README.vi.md | 4 ++++
docs/channels/feishu/README.zh.md | 21 +++++++++------------
6 files changed, 29 insertions(+), 12 deletions(-)
diff --git a/docs/channels/feishu/README.fr.md b/docs/channels/feishu/README.fr.md
index 555dd2713..f1ff26480 100644
--- a/docs/channels/feishu/README.fr.md
+++ b/docs/channels/feishu/README.fr.md
@@ -46,3 +46,7 @@ Feishu (nom international : Lark) est une plateforme de collaboration d'entrepri
> `encrypt_key` et `verification_token` sont optionnels ; l'activation du chiffrement des événements est recommandée pour les environnements de production.
>
> Pour les références d'emojis personnalisés, voir : [Liste des emojis Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
+
+## Limitations de plateforme
+
+> ⚠️ **Le canal Feishu ne prend pas en charge les appareils 32 bits.** Le SDK Feishu ne fournit que des builds 64 bits. Les architectures 32 bits (armv6, armv7, mipsle, etc.) ne peuvent pas utiliser le canal Feishu. Pour la messagerie sur des appareils 32 bits, utilisez Telegram, Discord ou OneBot.
diff --git a/docs/channels/feishu/README.ja.md b/docs/channels/feishu/README.ja.md
index ca467dd4c..4bb75a734 100644
--- a/docs/channels/feishu/README.ja.md
+++ b/docs/channels/feishu/README.ja.md
@@ -46,3 +46,7 @@
> `encrypt_key` と `verification_token` はオプションですが、本番環境ではイベント暗号化を有効にすることを推奨します。
>
> カスタム絵文字の参考:[飛書絵文字リスト](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
+
+## プラットフォーム制限
+
+> ⚠️ **飛書チャネルは 32 ビットデバイスをサポートしていません。** 飛書 SDK は 64 ビットビルドのみ提供しています。armv6 / armv7 / mipsle などの 32 ビットアーキテクチャでは飛書チャネルを使用できません。32 ビットデバイスでのメッセージングには、Telegram、Discord、または OneBot をご利用ください。
diff --git a/docs/channels/feishu/README.md b/docs/channels/feishu/README.md
index a991c76af..2aeaa31cb 100644
--- a/docs/channels/feishu/README.md
+++ b/docs/channels/feishu/README.md
@@ -46,3 +46,7 @@ Feishu (international name: Lark) is an enterprise collaboration platform by Byt
> `encrypt_key` and `verification_token` are optional; enabling event encryption is recommended for production environments.
>
> For custom emoji references, see: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
+
+## Platform Limitations
+
+> ⚠️ **Feishu channel does not support 32-bit devices.** The Feishu SDK only provides 64-bit builds. Devices running armv6, armv7, mipsle, or other 32-bit architectures cannot use the Feishu channel. For messaging on 32-bit devices, use Telegram, Discord, or OneBot instead.
diff --git a/docs/channels/feishu/README.pt-br.md b/docs/channels/feishu/README.pt-br.md
index 00a8c95b0..5b5fcaf68 100644
--- a/docs/channels/feishu/README.pt-br.md
+++ b/docs/channels/feishu/README.pt-br.md
@@ -46,3 +46,7 @@ Feishu (nome internacional: Lark) é uma plataforma de colaboração empresarial
> `encrypt_key` e `verification_token` são opcionais; recomenda-se habilitar a criptografia de eventos em ambientes de produção.
>
> Para referências de emojis personalizados, consulte: [Lista de Emojis do Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
+
+## Limitações de Plataforma
+
+> ⚠️ **O canal Feishu não suporta dispositivos 32 bits.** O SDK do Feishu fornece apenas builds 64 bits. Arquiteturas 32 bits (armv6, armv7, mipsle, etc.) não podem usar o canal Feishu. Para mensagens em dispositivos 32 bits, use Telegram, Discord ou OneBot.
diff --git a/docs/channels/feishu/README.vi.md b/docs/channels/feishu/README.vi.md
index 600dce260..e704b7794 100644
--- a/docs/channels/feishu/README.vi.md
+++ b/docs/channels/feishu/README.vi.md
@@ -46,3 +46,7 @@ Feishu (tên quốc tế: Lark) là nền tảng cộng tác doanh nghiệp củ
> `encrypt_key` và `verification_token` là tùy chọn; nên bật mã hóa sự kiện trong môi trường sản xuất.
>
> Tham khảo emoji tùy chỉnh: [Danh sách Emoji Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
+
+## Giới hạn nền tảng
+
+> ⚠️ **Kênh Feishu không hỗ trợ thiết bị 32 bit.** SDK Feishu chỉ cung cấp bản build 64 bit. Các kiến trúc 32 bit (armv6, armv7, mipsle, v.v.) không thể sử dụng kênh Feishu. Để nhắn tin trên thiết bị 32 bit, hãy dùng Telegram, Discord hoặc OneBot.
diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md
index a967dbdc3..6e2829547 100644
--- a/docs/channels/feishu/README.zh.md
+++ b/docs/channels/feishu/README.zh.md
@@ -35,16 +35,13 @@
## 设置流程
-1. 前往 [飞书开放平台](https://open.feishu.cn/)(国际版用户请前往 [Lark 开放平台](https://open.larksuite.com/))创建应用
-2. 在应用设置中启用**机器人**能力
-3. 创建版本并发布应用(应用发布后配置才会生效)
-4. 获取 **App ID**(以 `cli_` 开头)和 **App Secret**
-5. 将 App ID 和 App Secret 填入 PicoClaw 配置文件
-6. 运行 `picoclaw gateway` 启动服务
-7. 在飞书中搜索机器人名称,开始对话
+1. 前往 [飞书开放平台](https://open.feishu.cn/)(国际版用户请前往 [Lark 开放平台](https://open.larksuite.com/))创建应用程序
+2. 获取 App ID 和 App Secret
+3. 配置事件订阅和Webhook URL
+4. 设置加密(可选,生产环境建议启用)
+5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
+6. 自定义你希望 PicoClaw react 你消息时的表情(可选, Reference URL: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce))
-> PicoClaw 使用 WebSocket/SDK 模式连接飞书,无需配置公网回调地址或 Webhook URL。
->
-> `encrypt_key` 和 `verification_token` 为可选项,生产环境建议启用事件加密。
->
-> 自定义表情参考:[飞书表情列表](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
+## 平台限制
+
+> ⚠️ **飞书通道不支持 32 位设备。** 飞书官方 SDK 仅提供 64 位构建,armv6 / armv7 / mipsle 等 32 位架构无法使用飞书通道。如需在 32 位设备上接入即时通讯,请改用 Telegram、Discord 或 OneBot 等通道。
From f901af8cbc2d331fb500d24bf8b68a746538c4d9 Mon Sep 17 00:00:00 2001
From: Liu Yuan
Date: Sat, 21 Mar 2026 22:38:03 +0800
Subject: [PATCH 43/44] feat(tools): add exec tool enhancement with background
execution and PTY support (#1752)
- Unified exec tool with actions: run/list/poll/read/write/send-keys/kill
- PTY support using creack/pty library
- Process session management with background execution
- Process group kill for cleaning up child processes
- Session cleanup: 30-minute TTL for old sessions
- Output buffer: 100MB limit with truncation
Actions:
- run: execute command (sync or background)
- list: list all sessions
- poll: check session status
- read: read session output
- write: send input to session stdin
- send-keys: send special keys (up, down, ctrl-c, enter, etc.)
- kill: terminate session
Tests:
- PTY: allowed commands, write/read, poll, kill, process group kill
- Non-PTY: background execution, list, read, write, poll, kill, process group kill
- Session management: add/get/remove/list/cleanup
---
go.mod | 3 +-
go.sum | 1 +
pkg/agent/instance_test.go | 5 +-
pkg/tools/session.go | 252 +++++++
pkg/tools/session_process_unix.go | 14 +
pkg/tools/session_process_windows.go | 13 +
pkg/tools/session_test.go | 99 +++
pkg/tools/shell.go | 742 ++++++++++++++++++++-
pkg/tools/shell_test.go | 962 ++++++++++++++++++++++++++-
pkg/tools/shell_timeout_unix_test.go | 1 +
pkg/tools/types.go | 21 +
11 files changed, 2082 insertions(+), 31 deletions(-)
create mode 100644 pkg/tools/session.go
create mode 100644 pkg/tools/session_process_unix.go
create mode 100644 pkg/tools/session_process_windows.go
create mode 100644 pkg/tools/session_test.go
diff --git a/go.mod b/go.mod
index cfc930d37..744e05e17 100644
--- a/go.mod
+++ b/go.mod
@@ -3,12 +3,13 @@ module github.com/sipeed/picoclaw
go 1.25.8
require (
- github.com/BurntSushi/toml v1.6.0
fyne.io/systray v1.12.0
+ 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/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.4.0
+ github.com/creack/pty v1.1.9
github.com/ergochat/irc-go v0.6.0
github.com/ergochat/readline v0.1.3
github.com/gdamore/tcell/v2 v2.13.8
diff --git a/go.sum b/go.sum
index f24b997d4..dc82d46ef 100644
--- a/go.sum
+++ b/go.sum
@@ -37,6 +37,7 @@ github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go
index b3318ad1f..84cfa81df 100644
--- a/pkg/agent/instance_test.go
+++ b/pkg/agent/instance_test.go
@@ -236,8 +236,9 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
t.Fatal("exec tool not registered")
}
execResult := execTool.Execute(context.Background(), map[string]any{
- "command": "cat " + filepath.Base(mediaPath),
- "working_dir": mediaDir,
+ "action": "run",
+ "command": "cat " + filepath.Base(mediaPath),
+ "cwd": mediaDir,
})
if execResult.IsError {
t.Fatalf("exec should allow media temp dir, got: %s", execResult.ForLLM)
diff --git a/pkg/tools/session.go b/pkg/tools/session.go
new file mode 100644
index 000000000..e32bc3ddf
--- /dev/null
+++ b/pkg/tools/session.go
@@ -0,0 +1,252 @@
+package tools
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+const maxOutputBufferSize = 100 * 1024 * 1024 // 100MB
+
+const outputTruncateMarker = "\n... [output truncated, exceeded 100MB]\n"
+
+// PtyKeyMode represents arrow key encoding mode for PTY sessions.
+// Programs send smkx/rmkx sequences to switch between CSI and SS3 modes.
+type PtyKeyMode uint8
+
+const (
+ PtyKeyModeCSI PtyKeyMode = iota // triggered by rmkx (\x1b[?1l)
+ PtyKeyModeSS3 // triggered by smkx (\x1b[?1h)
+)
+
+const PtyKeyModeNotFound PtyKeyMode = 255
+
+var (
+ ErrSessionNotFound = errors.New("session not found")
+ ErrSessionDone = errors.New("session already completed")
+ ErrPTYNotSupported = errors.New("PTY is not supported on this platform")
+ ErrNoStdin = errors.New("no stdin available")
+)
+
+type ProcessSession struct {
+ mu sync.Mutex
+ ID string
+ PID int
+ Command string
+ PTY bool
+ Background bool
+ StartTime int64
+ ExitCode int
+ Status string
+ stdinWriter io.Writer
+ stdoutPipe io.Reader
+ outputBuffer *bytes.Buffer
+ outputTruncated bool
+ ptyMaster *os.File
+
+ // ptyKeyMode tracks arrow key encoding mode (CSI vs SS3)
+ ptyKeyMode PtyKeyMode
+}
+
+func (s *ProcessSession) IsDone() bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.Status == "done" || s.Status == "exited"
+}
+
+func (s *ProcessSession) GetPtyKeyMode() PtyKeyMode {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.ptyKeyMode
+}
+
+func (s *ProcessSession) SetPtyKeyMode(mode PtyKeyMode) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.ptyKeyMode = mode
+}
+
+func (s *ProcessSession) GetStatus() string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.Status
+}
+
+func (s *ProcessSession) SetStatus(status string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.Status = status
+}
+
+func (s *ProcessSession) GetExitCode() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.ExitCode
+}
+
+func (s *ProcessSession) SetExitCode(code int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.ExitCode = code
+}
+
+func (s *ProcessSession) killProcess() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if s.Status != "running" {
+ return ErrSessionDone
+ }
+
+ pid := s.PID
+ if pid <= 0 {
+ return ErrSessionNotFound
+ }
+
+ if err := killProcessGroup(pid); err != nil {
+ return err
+ }
+
+ s.Status = "done"
+ s.ExitCode = -1
+ return nil
+}
+
+func (s *ProcessSession) Kill() error {
+ return s.killProcess()
+}
+
+func (s *ProcessSession) Write(data string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if s.Status != "running" {
+ return ErrSessionDone
+ }
+
+ var writer io.Writer
+ if s.PTY && s.ptyMaster != nil {
+ writer = s.ptyMaster
+ } else if s.stdinWriter != nil {
+ writer = s.stdinWriter
+ } else {
+ return ErrNoStdin
+ }
+
+ _, err := writer.Write([]byte(data))
+ return err
+}
+
+func (s *ProcessSession) Read() string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if s.outputBuffer.Len() == 0 {
+ return ""
+ }
+
+ data := s.outputBuffer.String()
+ s.outputBuffer.Reset()
+ return data
+}
+
+func (s *ProcessSession) ToSessionInfo() SessionInfo {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ return SessionInfo{
+ ID: s.ID,
+ Command: s.Command,
+ Status: s.Status,
+ PID: s.PID,
+ StartedAt: s.StartTime,
+ }
+}
+
+type SessionManager struct {
+ mu sync.RWMutex
+ sessions map[string]*ProcessSession
+}
+
+func NewSessionManager() *SessionManager {
+ sm := &SessionManager{
+ sessions: make(map[string]*ProcessSession),
+ }
+
+ // Start cleaner goroutine - runs every 5 minutes, cleans up sessions done for >30 minutes
+ go func() {
+ ticker := time.NewTicker(5 * time.Minute)
+ defer ticker.Stop()
+ for range ticker.C {
+ sm.cleanupOldSessions()
+ }
+ }()
+
+ return sm
+}
+
+// cleanupOldSessions removes sessions that are done and older than 30 minutes
+func (sm *SessionManager) cleanupOldSessions() {
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+
+ cutoff := time.Now().Add(-30 * time.Minute)
+ for id, session := range sm.sessions {
+ if session.IsDone() && session.StartTime < cutoff.Unix() {
+ delete(sm.sessions, id)
+ }
+ }
+}
+
+func (sm *SessionManager) Add(session *ProcessSession) {
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+ sm.sessions[session.ID] = session
+}
+
+func (sm *SessionManager) Get(sessionID string) (*ProcessSession, error) {
+ sm.mu.RLock()
+ defer sm.mu.RUnlock()
+
+ session, ok := sm.sessions[sessionID]
+ if !ok {
+ return nil, ErrSessionNotFound
+ }
+
+ return session, nil
+}
+
+func (sm *SessionManager) Remove(sessionID string) {
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+ delete(sm.sessions, sessionID)
+}
+
+func (sm *SessionManager) List() []SessionInfo {
+ sm.mu.RLock()
+ defer sm.mu.RUnlock()
+
+ result := make([]SessionInfo, 0, len(sm.sessions))
+ for _, session := range sm.sessions {
+ result = append(result, session.ToSessionInfo())
+ }
+
+ return result
+}
+
+func generateSessionID() string {
+ return uuid.New().String()[:8]
+}
+
+type SessionInfo struct {
+ ID string `json:"id"`
+ Command string `json:"command"`
+ Status string `json:"status"`
+ PID int `json:"pid"`
+ StartedAt int64 `json:"startedAt"`
+}
diff --git a/pkg/tools/session_process_unix.go b/pkg/tools/session_process_unix.go
new file mode 100644
index 000000000..2fe30166e
--- /dev/null
+++ b/pkg/tools/session_process_unix.go
@@ -0,0 +1,14 @@
+//go:build !windows
+
+package tools
+
+import (
+ "syscall"
+)
+
+func killProcessGroup(pid int) error {
+ if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
+ _ = syscall.Kill(pid, syscall.SIGKILL)
+ }
+ return nil
+}
diff --git a/pkg/tools/session_process_windows.go b/pkg/tools/session_process_windows.go
new file mode 100644
index 000000000..7cf558954
--- /dev/null
+++ b/pkg/tools/session_process_windows.go
@@ -0,0 +1,13 @@
+//go:build windows
+
+package tools
+
+import (
+ "os/exec"
+ "strconv"
+)
+
+func killProcessGroup(pid int) error {
+ _ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run()
+ return nil
+}
diff --git a/pkg/tools/session_test.go b/pkg/tools/session_test.go
new file mode 100644
index 000000000..6cfe72a10
--- /dev/null
+++ b/pkg/tools/session_test.go
@@ -0,0 +1,99 @@
+package tools
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestSessionManager_AddGet(t *testing.T) {
+ sm := NewSessionManager()
+ session := &ProcessSession{
+ ID: "test-1",
+ Command: "echo hello",
+ Status: "running",
+ StartTime: 1000,
+ }
+
+ sm.Add(session)
+
+ got, err := sm.Get("test-1")
+ require.NoError(t, err)
+ require.Equal(t, "test-1", got.ID)
+}
+
+func TestSessionManager_Remove(t *testing.T) {
+ sm := NewSessionManager()
+ session := &ProcessSession{
+ ID: "test-1",
+ Command: "echo hello",
+ Status: "running",
+ StartTime: 1000,
+ }
+ sm.Add(session)
+ sm.Remove("test-1")
+
+ _, err := sm.Get("test-1")
+ require.ErrorIs(t, err, ErrSessionNotFound)
+}
+
+func TestSessionManager_List(t *testing.T) {
+ sm := NewSessionManager()
+ sm.Add(&ProcessSession{
+ ID: "test-1",
+ Command: "echo hello",
+ Status: "running",
+ StartTime: 1000,
+ })
+ sm.Add(&ProcessSession{
+ ID: "test-2",
+ Command: "echo world",
+ Status: "running",
+ StartTime: 1001,
+ })
+ sm.Add(&ProcessSession{
+ ID: "test-3",
+ Command: "echo done",
+ Status: "done",
+ StartTime: 1002,
+ })
+
+ sessions := sm.List()
+ require.Len(t, sessions, 3)
+
+ ids := make(map[string]bool)
+ for _, s := range sessions {
+ ids[s.ID] = true
+ }
+ require.True(t, ids["test-1"])
+ require.True(t, ids["test-2"])
+ require.True(t, ids["test-3"])
+}
+
+func TestProcessSession_IsDone(t *testing.T) {
+ session := &ProcessSession{Status: "running"}
+ require.False(t, session.IsDone())
+
+ session.Status = "done"
+ require.True(t, session.IsDone())
+
+ session.Status = "exited"
+ require.True(t, session.IsDone())
+}
+
+func TestProcessSession_ToSessionInfo(t *testing.T) {
+ session := &ProcessSession{
+ ID: "test-1",
+ PID: 12345,
+ Command: "echo hello",
+ Status: "running",
+ StartTime: 1000,
+ }
+
+ info := session.ToSessionInfo()
+ require.Equal(t, "test-1", info.ID)
+ require.Equal(t, "echo hello", info.Command)
+ require.Equal(t, "running", info.Status)
+ require.Equal(t, 12345, info.PID)
+ require.Equal(t, int64(1000), info.StartedAt)
+}
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index 78ad2b26d..f3869cc1c 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -3,20 +3,37 @@ package tools
import (
"bytes"
"context"
+ "encoding/json"
"errors"
"fmt"
+ "io"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
+ "sync"
+ "syscall"
"time"
+ "github.com/creack/pty"
+
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
)
+var (
+ globalSessionManager = NewSessionManager()
+ sessionManagerMu sync.RWMutex
+)
+
+func getSessionManager() *SessionManager {
+ sessionManagerMu.RLock()
+ defer sessionManagerMu.RUnlock()
+ return globalSessionManager
+}
+
type ExecTool struct {
workingDir string
timeout time.Duration
@@ -26,6 +43,7 @@ type ExecTool struct {
allowedPathPatterns []*regexp.Regexp
restrictToWorkspace bool
allowRemote bool
+ sessionManager *SessionManager
}
var (
@@ -145,7 +163,7 @@ func NewExecToolWithConfig(
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
}
- timeout := 60 * time.Second
+ var timeout time.Duration
if config != nil && config.Tools.Exec.TimeoutSeconds > 0 {
timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second
}
@@ -159,6 +177,7 @@ func NewExecToolWithConfig(
allowedPathPatterns: allowedPathPatterns,
restrictToWorkspace: restrict,
allowRemote: allowRemote,
+ sessionManager: getSessionManager(),
}, nil
}
@@ -167,27 +186,146 @@ func (t *ExecTool) Name() string {
}
func (t *ExecTool) Description() string {
- return "Execute a shell command and return its output. Use with caution."
+ return `Execute shell commands. Use background=true for long-running commands (returns sessionId). Use pty=true for interactive commands (can combine with background=true). Use poll/read/write/send-keys/kill with sessionId to manage background sessions. Sessions auto-cleanup 30 minutes after process exits; use kill to terminate early. Output buffer limit: 100MB.`
}
func (t *ExecTool) Parameters() map[string]any {
return map[string]any{
- "type": "object",
- "properties": map[string]any{
- "command": map[string]any{
- "type": "string",
- "description": "The shell command to execute",
+ "oneOf": []map[string]any{
+ {
+ "type": "object",
+ "properties": map[string]any{
+ "action": map[string]any{"const": "run", "description": "Execute a shell command"},
+ "command": map[string]any{"type": "string", "description": "Shell command to execute"},
+ "background": map[string]any{
+ "type": "string",
+ "description": "Run in background immediately",
+ },
+ "pty": map[string]any{
+ "type": "string",
+ "description": "Run in a pseudo-terminal (PTY) when available",
+ },
+ "cwd": map[string]any{
+ "type": "string",
+ "description": "Working directory for the command",
+ },
+ "timeout": map[string]any{
+ "type": "integer",
+ "description": "Timeout in seconds (default: 0 = no timeout, kills process on expiry)",
+ },
+ },
+ "required": []string{"action", "command"},
},
- "working_dir": map[string]any{
- "type": "string",
- "description": "Optional working directory for the command",
+ {
+ "type": "object",
+ "properties": map[string]any{
+ "action": map[string]any{"const": "list", "description": "List all active sessions"},
+ },
+ "required": []string{"action"},
+ },
+ {
+ "type": "object",
+ "properties": map[string]any{
+ "action": map[string]any{
+ "const": "poll",
+ "description": "Check session status. Returns: {sessionId, status: running|done, exitCode}. exitCode only meaningful when status=done",
+ },
+ "sessionId": map[string]any{
+ "type": "string",
+ "description": "Session ID returned from background command",
+ },
+ },
+ "required": []string{"action", "sessionId"},
+ },
+ {
+ "type": "object",
+ "properties": map[string]any{
+ "action": map[string]any{
+ "const": "read",
+ "description": "Read output from session. Returns: {sessionId, output, status: running|done}",
+ },
+ "sessionId": map[string]any{
+ "type": "string",
+ "description": "Session ID returned from background command",
+ },
+ },
+ "required": []string{"action", "sessionId"},
+ },
+ {
+ "type": "object",
+ "properties": map[string]any{
+ "action": map[string]any{
+ "const": "write",
+ "description": "Send input to session stdin (only when status=running)",
+ },
+ "sessionId": map[string]any{
+ "type": "string",
+ "description": "Session ID returned from background command",
+ },
+ "data": map[string]any{"type": "string", "description": "Data to write to session stdin."},
+ },
+ "required": []string{"action", "sessionId", "data"},
+ },
+ {
+ "type": "object",
+ "properties": map[string]any{
+ "action": map[string]any{"const": "kill", "description": "Terminate session"},
+ "sessionId": map[string]any{
+ "type": "string",
+ "description": "Session ID returned from background command",
+ },
+ },
+ "required": []string{"action", "sessionId"},
+ },
+ {
+ "type": "object",
+ "properties": map[string]any{
+ "action": map[string]any{
+ "const": "send-keys",
+ "description": "Send special keys to PTY session. Keys: down/up/left/right/enter/escape/tab/backspace/ctrl-c/ctrl-d/ctrl-z. Multiple keys separated by comma",
+ },
+ "sessionId": map[string]any{
+ "type": "string",
+ "description": "Session ID returned from background command",
+ },
+ "keys": map[string]any{
+ "type": "string",
+ "description": "Comma-separated key names (optional spaces around comma). Valid keys: up, down, left, right, enter, tab, escape, backspace, ctrl-c, ctrl-d, home, end, pageup, pagedown, f1-f12.",
+ },
+ },
+ "required": []string{"action", "sessionId", "keys"},
},
},
- "required": []string{"command"},
}
}
func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ action, _ := args["action"].(string)
+ if action == "" {
+ return ErrorResult("action is required")
+ }
+
+ switch action {
+ case "run":
+ return t.executeRun(ctx, args)
+ case "list":
+ return t.executeList()
+ case "poll":
+ return t.executePoll(args)
+ case "read":
+ return t.executeRead(args)
+ case "write":
+ return t.executeWrite(args)
+ case "kill":
+ return t.executeKill(args)
+ case "send-keys":
+ return t.executeSendKeys(args)
+ default:
+ return ErrorResult(fmt.Sprintf("unknown action: %s", action))
+ }
+}
+
+func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolResult {
command, ok := args["command"].(string)
if !ok {
return ErrorResult("command is required")
@@ -206,8 +344,26 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
}
}
+ getBoolArg := func(key string) bool {
+ switch v := args[key].(type) {
+ case bool:
+ return v
+ case string:
+ return v == "true"
+ }
+ return false
+ }
+ isPty := getBoolArg("pty")
+ isBackground := getBoolArg("background")
+
+ if isPty {
+ if runtime.GOOS == "windows" {
+ return ErrorResult("PTY is not supported on Windows. Use background=true without pty.")
+ }
+ }
+
cwd := t.workingDir
- if wd, ok := args["working_dir"].(string); ok && wd != "" {
+ if wd, ok := args["cwd"].(string); ok && wd != "" {
if t.restrictToWorkspace && t.workingDir != "" {
resolvedWD, err := validatePathWithAllowPaths(wd, t.workingDir, true, t.allowedPathPatterns)
if err != nil {
@@ -253,6 +409,14 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
}
}
+ if isBackground {
+ return t.runBackground(ctx, command, cwd, isPty)
+ }
+
+ return t.runSync(ctx, command, cwd)
+}
+
+func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult {
// timeout == 0 means no timeout
var cmdCtx context.Context
var cancel context.CancelFunc
@@ -361,6 +525,560 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
}
}
+func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEnabled bool) *ToolResult {
+ sessionID := generateSessionID()
+ session := &ProcessSession{
+ ID: sessionID,
+ Command: command,
+ PTY: ptyEnabled,
+ Background: true,
+ StartTime: time.Now().Unix(),
+ Status: "running",
+ ptyKeyMode: PtyKeyModeCSI,
+ }
+
+ var cmd *exec.Cmd
+ if runtime.GOOS == "windows" {
+ cmd = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", command)
+ } else {
+ cmd = exec.Command("sh", "-c", command)
+ }
+ if cwd != "" {
+ cmd.Dir = cwd
+ }
+
+ prepareCommandForTermination(cmd)
+
+ var stdoutReader io.ReadCloser
+ var stderrReader io.ReadCloser
+ var stdinWriter io.WriteCloser
+
+ if ptyEnabled {
+ ptmx, tty, err := pty.Open()
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("failed to create PTY: %v", err))
+ }
+
+ cmd.Stdin = tty
+ cmd.Stdout = tty
+ cmd.Stderr = tty
+
+ // For PTY, we need Setsid to create a new session.
+ // Note: Setsid and Setpgid conflict, so we must replace SysProcAttr entirely.
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
+
+ session.ptyMaster = ptmx
+ } else {
+ var err error
+ stdoutReader, err = cmd.StdoutPipe()
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("failed to create stdout pipe: %v", err))
+ }
+ stderrReader, err = cmd.StderrPipe()
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("failed to create stderr pipe: %v", err))
+ }
+ stdinWriter, err = cmd.StdinPipe()
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("failed to create stdin pipe: %v", err))
+ }
+ session.stdoutPipe = io.MultiReader(stdoutReader, stderrReader)
+ session.stdinWriter = stdinWriter
+ }
+
+ if err := cmd.Start(); err != nil {
+ if session.ptyMaster != nil {
+ session.ptyMaster.Close()
+ }
+ return ErrorResult(fmt.Sprintf("failed to start command: %v", err))
+ }
+
+ session.PID = cmd.Process.Pid
+ t.sessionManager.Add(session)
+
+ session.outputBuffer = &bytes.Buffer{}
+
+ // PTY mode: read from ptyMaster and wait for process
+ // Note: On Linux, closing ptyMaster doesn't interrupt blocking Read() calls,
+ // so we need cmd.Wait() in a separate goroutine to detect process exit.
+ if session.PTY && session.ptyMaster != nil {
+ go func() {
+ cmd.Wait() // Wait for process to exit
+ session.mu.Lock()
+ if cmd.ProcessState != nil {
+ session.ExitCode = cmd.ProcessState.ExitCode()
+ }
+ session.Status = "done"
+ session.mu.Unlock()
+ }()
+
+ go func() {
+ buf := make([]byte, 4096)
+ for {
+ n, err := session.ptyMaster.Read(buf)
+ if n > 0 {
+ raw := string(buf[:n])
+ if mode := detectPtyKeyMode(raw); mode != PtyKeyModeNotFound && mode != session.GetPtyKeyMode() {
+ session.SetPtyKeyMode(mode)
+ }
+
+ session.mu.Lock()
+ if session.outputBuffer.Len() >= maxOutputBufferSize {
+ if !session.outputTruncated {
+ session.outputBuffer.WriteString(outputTruncateMarker)
+ session.outputTruncated = true
+ }
+ } else {
+ session.outputBuffer.Write(buf[:n])
+ }
+ session.mu.Unlock()
+ }
+ if err != nil {
+ break
+ }
+ }
+ }()
+ } else {
+ // Non-PTY mode: single goroutine reads pipes.
+ // When Read() returns EOF (pipe closed), we break.
+ // When process exits, OS closes pipe write end → Read() returns EOF → we exit.
+ go func() {
+ buf := make([]byte, 4096)
+
+ // Read stdout
+ for {
+ n, err := stdoutReader.Read(buf)
+ if n > 0 {
+ session.mu.Lock()
+ if session.outputBuffer.Len() >= maxOutputBufferSize {
+ if !session.outputTruncated {
+ session.outputBuffer.WriteString(outputTruncateMarker)
+ session.outputTruncated = true
+ }
+ } else {
+ session.outputBuffer.Write(buf[:n])
+ }
+ session.mu.Unlock()
+ }
+ if err != nil {
+ break
+ }
+ }
+
+ // Read stderr
+ for {
+ n, err := stderrReader.Read(buf)
+ if n > 0 {
+ session.mu.Lock()
+ if session.outputBuffer.Len() >= maxOutputBufferSize {
+ if !session.outputTruncated {
+ session.outputBuffer.WriteString(outputTruncateMarker)
+ session.outputTruncated = true
+ }
+ } else {
+ session.outputBuffer.Write(buf[:n])
+ }
+ session.mu.Unlock()
+ }
+ if err != nil {
+ break
+ }
+ }
+
+ // All pipes closed, get exit status
+ if stdinWriter != nil {
+ stdinWriter.Close()
+ }
+ cmd.Wait()
+
+ session.mu.Lock()
+ if cmd.ProcessState != nil {
+ session.ExitCode = cmd.ProcessState.ExitCode()
+ }
+ session.Status = "done"
+ session.mu.Unlock()
+ }()
+ }
+
+ resp := ExecResponse{
+ SessionID: sessionID,
+ Status: "running",
+ }
+ data, _ := json.Marshal(resp)
+ return &ToolResult{
+ ForLLM: string(data),
+ ForUser: fmt.Sprintf("Session %s started", sessionID),
+ IsError: false,
+ }
+}
+
+func (t *ExecTool) executeList() *ToolResult {
+ sessions := t.sessionManager.List()
+ resp := ExecResponse{
+ Sessions: sessions,
+ }
+ data, _ := json.Marshal(resp)
+ return &ToolResult{
+ ForLLM: string(data),
+ ForUser: fmt.Sprintf("%d active sessions", len(sessions)),
+ IsError: false,
+ }
+}
+
+func (t *ExecTool) executePoll(args map[string]any) *ToolResult {
+ sessionID, ok := args["sessionId"].(string)
+ if !ok {
+ return ErrorResult("sessionId is required")
+ }
+
+ session, err := t.sessionManager.Get(sessionID)
+ if err != nil {
+ if errors.Is(err, ErrSessionNotFound) {
+ return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
+ }
+ return ErrorResult(err.Error())
+ }
+
+ resp := ExecResponse{
+ SessionID: sessionID,
+ Status: session.GetStatus(),
+ ExitCode: session.GetExitCode(),
+ }
+ data, _ := json.Marshal(resp)
+ return &ToolResult{
+ ForLLM: string(data),
+ IsError: false,
+ }
+}
+
+func (t *ExecTool) executeRead(args map[string]any) *ToolResult {
+ sessionID, ok := args["sessionId"].(string)
+ if !ok {
+ return ErrorResult("sessionId is required")
+ }
+
+ session, err := t.sessionManager.Get(sessionID)
+ if err != nil {
+ if errors.Is(err, ErrSessionNotFound) {
+ return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
+ }
+ return ErrorResult(err.Error())
+ }
+
+ output := session.Read()
+
+ resp := ExecResponse{
+ SessionID: sessionID,
+ Output: output,
+ Status: session.GetStatus(),
+ }
+ data, _ := json.Marshal(resp)
+ return &ToolResult{
+ ForLLM: string(data),
+ IsError: false,
+ }
+}
+
+func (t *ExecTool) executeWrite(args map[string]any) *ToolResult {
+ sessionID, ok := args["sessionId"].(string)
+ if !ok {
+ return ErrorResult("sessionId is required")
+ }
+
+ data, ok := args["data"].(string)
+ if !ok {
+ return ErrorResult("data is required")
+ }
+
+ session, err := t.sessionManager.Get(sessionID)
+ if err != nil {
+ if errors.Is(err, ErrSessionNotFound) {
+ return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
+ }
+ return ErrorResult(err.Error())
+ }
+
+ if session.IsDone() {
+ return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
+ }
+
+ if err := session.Write(data); err != nil {
+ if errors.Is(err, ErrSessionDone) {
+ return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
+ }
+ return ErrorResult(fmt.Sprintf("failed to write to session: %v", err))
+ }
+
+ resp := ExecResponse{
+ SessionID: sessionID,
+ Status: session.GetStatus(),
+ }
+ respData, _ := json.Marshal(resp)
+ return &ToolResult{
+ ForLLM: string(respData),
+ IsError: false,
+ }
+}
+
+func (t *ExecTool) executeKill(args map[string]any) *ToolResult {
+ sessionID, ok := args["sessionId"].(string)
+ if !ok {
+ return ErrorResult("sessionId is required")
+ }
+
+ session, err := t.sessionManager.Get(sessionID)
+ if err != nil {
+ if errors.Is(err, ErrSessionNotFound) {
+ return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
+ }
+ return ErrorResult(err.Error())
+ }
+
+ if session.IsDone() {
+ return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
+ }
+
+ if err := session.Kill(); err != nil {
+ return ErrorResult(fmt.Sprintf("failed to kill session: %v", err))
+ }
+
+ t.sessionManager.Remove(sessionID)
+
+ resp := ExecResponse{
+ SessionID: sessionID,
+ Status: "done",
+ }
+ data, _ := json.Marshal(resp)
+ return &ToolResult{
+ ForLLM: string(data),
+ ForUser: fmt.Sprintf("Session %s killed", sessionID),
+ IsError: false,
+ }
+}
+
+// keyMap maps key names to their escape sequences.
+var keyMap = map[string]string{
+ "enter": "\r",
+ "return": "\r",
+ "tab": "\t",
+ "escape": "\x1b",
+ "esc": "\x1b",
+ "space": " ",
+ "backspace": "\x7f",
+ "bspace": "\x7f",
+ "up": "\x1b[A",
+ "down": "\x1b[B",
+ "right": "\x1b[C",
+ "left": "\x1b[D",
+ "home": "\x1b[1~",
+ "end": "\x1b[4~",
+ "pageup": "\x1b[5~",
+ "pagedown": "\x1b[6~",
+ "pgup": "\x1b[5~",
+ "pgdn": "\x1b[6~",
+ "insert": "\x1b[2~",
+ "ic": "\x1b[2~",
+ "delete": "\x1b[3~",
+ "del": "\x1b[3~",
+ "dc": "\x1b[3~",
+ "btab": "\x1b[Z",
+ "f1": "\x1bOP",
+ "f2": "\x1bOQ",
+ "f3": "\x1bOR",
+ "f4": "\x1bOS",
+ "f5": "\x1b[15~",
+ "f6": "\x1b[17~",
+ "f7": "\x1b[18~",
+ "f8": "\x1b[19~",
+ "f9": "\x1b[20~",
+ "f10": "\x1b[21~",
+ "f11": "\x1b[23~",
+ "f12": "\x1b[24~",
+}
+
+// ss3KeysMap maps key names to SS3 escape sequences
+var ss3KeysMap = map[string]string{
+ "up": "\x1bOA",
+ "down": "\x1bOB",
+ "right": "\x1bOC",
+ "left": "\x1bOD",
+ "home": "\x1bOH",
+ "end": "\x1bOF",
+}
+
+func detectPtyKeyMode(raw string) PtyKeyMode {
+ const SMKX = "\x1b[?1h"
+ const RMKX = "\x1b[?1l"
+
+ lastSmkx := strings.LastIndex(raw, SMKX)
+ lastRmkx := strings.LastIndex(raw, RMKX)
+
+ if lastSmkx == -1 && lastRmkx == -1 {
+ return PtyKeyModeNotFound
+ }
+
+ if lastSmkx > lastRmkx {
+ return PtyKeyModeSS3
+ }
+ return PtyKeyModeCSI
+}
+
+// encodeKeyToken encodes a single key token into its escape sequence.
+// Supports:
+// - Named keys: "enter", "tab", "up", "ctrl-c", "alt-x", etc.
+// - Ctrl modifier: "ctrl-c" or "c-c" (sends Ctrl+char)
+// - Alt modifier: "alt-x" or "m-x" (sends ESC+char)
+func encodeKeyToken(token string, ptyKeyMode PtyKeyMode) (string, error) {
+ token = strings.ToLower(strings.TrimSpace(token))
+ if token == "" {
+ return "", nil
+ }
+
+ // Handle ctrl-X format (c-x)
+ if strings.HasPrefix(token, "c-") {
+ char := token[2]
+ if char >= 'a' && char <= 'z' {
+ return string(rune(char) & 0x1f), nil // ctrl-a through ctrl-z
+ }
+ return "", fmt.Errorf("invalid ctrl key: %s", token)
+ }
+
+ // Handle ctrl-X format (ctrl-x)
+ if strings.HasPrefix(token, "ctrl-") {
+ char := token[5]
+ if char >= 'a' && char <= 'z' {
+ return string(rune(char) & 0x1f), nil
+ }
+ return "", fmt.Errorf("invalid ctrl key: %s", token)
+ }
+
+ // Handle alt-X format (m-x or alt-x)
+ if strings.HasPrefix(token, "m-") || strings.HasPrefix(token, "alt-") {
+ var char string
+ if strings.HasPrefix(token, "m-") {
+ char = token[2:]
+ } else {
+ char = token[4:]
+ }
+ if len(char) == 1 {
+ return "\x1b" + char, nil
+ }
+ return "", fmt.Errorf("invalid alt key: %s", token)
+ }
+
+ // Handle shift modifier for special keys (shift-up, shift-down, etc.)
+ if strings.HasPrefix(token, "s-") || strings.HasPrefix(token, "shift-") {
+ var key string
+ if strings.HasPrefix(token, "s-") {
+ key = token[2:]
+ } else {
+ key = token[6:]
+ }
+ // Apply shift modifier: for single-char keys, return uppercase
+ if seq, ok := keyMap[key]; ok {
+ // For escape sequences, we can't easily add shift
+ // For single-char keys (letters), return uppercase
+ if len(seq) == 1 {
+ return strings.ToUpper(seq), nil
+ }
+ return seq, nil
+ }
+ return "", fmt.Errorf("unknown key with shift: %s", key)
+ }
+
+ if ptyKeyMode == PtyKeyModeSS3 {
+ if seq, ok := ss3KeysMap[token]; ok {
+ return seq, nil
+ }
+ }
+
+ if seq, ok := keyMap[token]; ok {
+ return seq, nil
+ }
+
+ return "", fmt.Errorf("unknown key: %s (use write action for text input)", token)
+}
+
+// encodeKeySequence encodes a slice of key tokens into a single string.
+func encodeKeySequence(tokens []string, ptyKeyMode PtyKeyMode) (string, error) {
+ var result string
+ for _, token := range tokens {
+ seq, err := encodeKeyToken(token, ptyKeyMode)
+ if err != nil {
+ return "", err
+ }
+ result += seq
+ }
+ return result, nil
+}
+
+func (t *ExecTool) executeSendKeys(args map[string]any) *ToolResult {
+ sessionID, ok := args["sessionId"].(string)
+ if !ok {
+ return ErrorResult("sessionId is required")
+ }
+
+ keysStr, ok := args["keys"].(string)
+ if !ok {
+ return ErrorResult("keys must be a string")
+ }
+
+ if keysStr == "" {
+ return ErrorResult("keys cannot be empty")
+ }
+
+ // Parse comma-separated key names
+ keyNames := strings.Split(keysStr, ",")
+ var keys []string
+ for _, k := range keyNames {
+ k = strings.TrimSpace(k)
+ if k != "" {
+ keys = append(keys, k)
+ }
+ }
+
+ if len(keys) == 0 {
+ return ErrorResult("keys cannot be empty")
+ }
+
+ session, err := t.sessionManager.Get(sessionID)
+ if err != nil {
+ if errors.Is(err, ErrSessionNotFound) {
+ return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
+ }
+ return ErrorResult(err.Error())
+ }
+
+ ptyKeyMode := session.GetPtyKeyMode()
+
+ data, err := encodeKeySequence(keys, ptyKeyMode)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("invalid key: %v", err))
+ }
+
+ if session.IsDone() {
+ return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
+ }
+
+ if err := session.Write(data); err != nil {
+ if errors.Is(err, ErrSessionDone) {
+ return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
+ }
+ return ErrorResult(fmt.Sprintf("failed to send keys: %v", err))
+ }
+
+ resp := ExecResponse{
+ SessionID: sessionID,
+ Status: "running",
+ Output: fmt.Sprintf("Sent keys: %v", keys),
+ }
+ respData, _ := json.Marshal(resp)
+ return &ToolResult{
+ ForLLM: string(respData),
+ IsError: false,
+ }
+}
+
func (t *ExecTool) guardCommand(command, cwd string) string {
cmd := strings.TrimSpace(command)
lower := strings.ToLower(cmd)
diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go
index f8f83ea74..a8de2f4c9 100644
--- a/pkg/tools/shell_test.go
+++ b/pkg/tools/shell_test.go
@@ -2,12 +2,16 @@ package tools
import (
"context"
+ "encoding/json"
"os"
"path/filepath"
+ "runtime"
"strings"
"testing"
"time"
+ "github.com/stretchr/testify/require"
+
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -20,6 +24,7 @@ func TestShellTool_Success(t *testing.T) {
ctx := context.Background()
args := map[string]any{
+ "action": "run",
"command": "echo 'hello world'",
}
@@ -50,6 +55,7 @@ func TestShellTool_Failure(t *testing.T) {
ctx := context.Background()
args := map[string]any{
+ "action": "run",
"command": "ls /nonexistent_directory_12345",
}
@@ -82,6 +88,7 @@ func TestShellTool_Timeout(t *testing.T) {
ctx := context.Background()
args := map[string]any{
+ "action": "run",
"command": "sleep 10",
}
@@ -112,8 +119,9 @@ func TestShellTool_WorkingDir(t *testing.T) {
ctx := context.Background()
args := map[string]any{
- "command": "cat test.txt",
- "working_dir": tmpDir,
+ "action": "run",
+ "command": "cat test.txt",
+ "cwd": tmpDir,
}
result := tool.Execute(ctx, args)
@@ -136,6 +144,7 @@ func TestShellTool_DangerousCommand(t *testing.T) {
ctx := context.Background()
args := map[string]any{
+ "action": "run",
"command": "rm -rf /",
}
@@ -159,6 +168,7 @@ func TestShellTool_DangerousCommand_KillBlocked(t *testing.T) {
ctx := context.Background()
args := map[string]any{
+ "action": "run",
"command": "kill 12345",
}
@@ -198,6 +208,7 @@ func TestShellTool_StderrCapture(t *testing.T) {
ctx := context.Background()
args := map[string]any{
+ "action": "run",
"command": "sh -c 'echo stdout; echo stderr >&2'",
}
@@ -222,6 +233,7 @@ func TestShellTool_OutputTruncation(t *testing.T) {
ctx := context.Background()
// Generate long output (>10000 chars)
args := map[string]any{
+ "action": "run",
"command": "python3 -c \"print('x' * 20000)\" || echo " + strings.Repeat("x", 20000),
}
@@ -251,8 +263,9 @@ func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) {
}
result := tool.Execute(context.Background(), map[string]any{
- "command": "pwd",
- "working_dir": outsideDir,
+ "action": "run",
+ "command": "pwd",
+ "cwd": outsideDir,
})
if !result.IsError {
@@ -289,8 +302,9 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) {
}
result := tool.Execute(context.Background(), map[string]any{
- "command": "cat secret.txt",
- "working_dir": link,
+ "action": "run",
+ "command": "cat secret.txt",
+ "cwd": link,
})
if !result.IsError {
@@ -312,7 +326,7 @@ func TestShellTool_RemoteChannelBlockedByDefault(t *testing.T) {
t.Fatalf("NewExecToolWithConfig() error: %v", err)
}
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
- result := tool.Execute(ctx, map[string]any{"command": "echo hi"})
+ result := tool.Execute(ctx, map[string]any{"action": "run", "command": "echo hi"})
if !result.IsError {
t.Fatal("expected remote-channel exec to be blocked")
@@ -333,7 +347,7 @@ func TestShellTool_InternalChannelAllowed(t *testing.T) {
t.Fatalf("NewExecToolWithConfig() error: %v", err)
}
ctx := WithToolContext(context.Background(), "cli", "direct")
- result := tool.Execute(ctx, map[string]any{"command": "echo hi"})
+ result := tool.Execute(ctx, map[string]any{"action": "run", "command": "echo hi"})
if result.IsError {
t.Fatalf("expected internal channel exec to succeed, got: %s", result.ForLLM)
@@ -373,7 +387,7 @@ func TestShellTool_AllowRemoteBypassesChannelCheck(t *testing.T) {
t.Fatalf("NewExecToolWithConfig() error: %v", err)
}
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
- result := tool.Execute(ctx, map[string]any{"command": "echo hi"})
+ result := tool.Execute(ctx, map[string]any{"action": "run", "command": "echo hi"})
if result.IsError {
t.Fatalf("expected allowRemote=true to permit remote channel, got: %s", result.ForLLM)
@@ -392,6 +406,7 @@ func TestShellTool_RestrictToWorkspace(t *testing.T) {
ctx := context.Background()
args := map[string]any{
+ "action": "run",
"command": "cat ../../etc/passwd",
}
@@ -429,7 +444,7 @@ func TestShellTool_DevNullAllowed(t *testing.T) {
}
for _, cmd := range commands {
- result := tool.Execute(context.Background(), map[string]any{"command": cmd})
+ result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd})
if result.IsError && strings.Contains(result.ForLLM, "blocked") {
t.Errorf("command should not be blocked: %s\n error: %s", cmd, result.ForLLM)
}
@@ -458,7 +473,7 @@ func TestShellTool_BlockDevices(t *testing.T) {
}
for _, cmd := range blocked {
- result := tool.Execute(context.Background(), map[string]any{"command": cmd})
+ result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd})
if !result.IsError {
t.Errorf("expected block device write to be blocked: %s", cmd)
}
@@ -482,7 +497,7 @@ func TestShellTool_SafePathsInWorkspaceRestriction(t *testing.T) {
}
for _, cmd := range commands {
- result := tool.Execute(context.Background(), map[string]any{"command": cmd})
+ result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd})
if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") {
t.Errorf("safe path should not be blocked by workspace check: %s\n error: %s", cmd, result.ForLLM)
}
@@ -498,6 +513,7 @@ func TestShellTool_ExitCodeDetails(t *testing.T) {
ctx := context.Background()
args := map[string]any{
+ "action": "run",
"command": "sh -c 'exit 42'",
}
@@ -534,6 +550,7 @@ func TestShellTool_TimeoutWithPartialOutput(t *testing.T) {
ctx := context.Background()
// Use a command that outputs immediately then sleeps
args := map[string]any{
+ "action": "run",
"command": "echo 'partial output before timeout' && sleep 30",
}
@@ -608,7 +625,9 @@ func TestShellTool_URLsNotBlocked(t *testing.T) {
}
for _, cmd := range commands {
- result := tool.Execute(context.Background(), map[string]any{"command": cmd})
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ result := tool.Execute(ctx, map[string]any{"action": "run", "command": cmd})
+ cancel()
if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") {
t.Errorf("command with URL should not be blocked by workspace check: %s\n error: %s", cmd, result.ForLLM)
}
@@ -633,7 +652,7 @@ func TestShellTool_FileURISandboxing(t *testing.T) {
}
for _, cmd := range blockedCommands {
- result := tool.Execute(context.Background(), map[string]any{"command": cmd})
+ result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd})
if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") {
t.Errorf("file:// URI outside workspace should be blocked: %s", cmd)
}
@@ -651,7 +670,7 @@ func TestShellTool_FileURISandboxing(t *testing.T) {
}
for _, cmd := range allowedCommands {
- result := tool.Execute(context.Background(), map[string]any{"command": cmd})
+ result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd})
if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") {
t.Errorf("file:// URI inside workspace should be allowed: %s\n error: %s", cmd, result.ForLLM)
}
@@ -677,9 +696,920 @@ func TestShellTool_URLBypassPrevented(t *testing.T) {
}
for _, cmd := range blockedCommands {
- result := tool.Execute(context.Background(), map[string]any{"command": cmd})
+ result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd})
if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") {
t.Errorf("bypass attempt should be blocked: %q\n got: %s", cmd, result.ForLLM)
}
}
}
+
+func TestShellTool_Background_ReturnsImmediately(t *testing.T) {
+ tool, err := NewExecTool("", false)
+ require.NoError(t, err)
+
+ ctx := context.Background()
+ args := map[string]any{
+ "action": "run",
+ "command": "sleep 5",
+ "background": "true",
+ }
+
+ start := time.Now()
+ result := tool.Execute(ctx, args)
+ elapsed := time.Since(start)
+
+ require.False(t, result.IsError, "background run should not error: %s", result.ForLLM)
+ require.Less(t, elapsed, time.Second, "background run should return immediately")
+ require.Contains(t, result.ForLLM, "sessionId")
+}
+
+func TestShellTool_List_Empty(t *testing.T) {
+ tool, err := NewExecTool("", false)
+ require.NoError(t, err)
+
+ sm := NewSessionManager()
+ tool.sessionManager = sm
+
+ ctx := context.Background()
+ args := map[string]any{"action": "list"}
+
+ result := tool.Execute(ctx, args)
+ require.False(t, result.IsError)
+ require.Contains(t, result.ForUser, "0 active sessions")
+}
+
+func TestShellTool_RunBackground_List(t *testing.T) {
+ tool, err := NewExecTool("", false)
+ require.NoError(t, err)
+
+ sm := NewSessionManager()
+ tool.sessionManager = sm
+
+ ctx := WithToolContext(context.Background(), "cli", "test")
+
+ runResult := tool.Execute(ctx, map[string]any{
+ "action": "run",
+ "command": "sleep 10",
+ "background": "true",
+ })
+ require.False(t, runResult.IsError, "run should succeed: %s", runResult.ForLLM)
+
+ var resp ExecResponse
+ err = json.Unmarshal([]byte(runResult.ForLLM), &resp)
+ require.NoError(t, err)
+ require.NotEmpty(t, resp.SessionID)
+
+ time.Sleep(100 * time.Millisecond)
+
+ listResult := tool.Execute(ctx, map[string]any{"action": "list"})
+ require.False(t, listResult.IsError)
+
+ var listResp ExecResponse
+ err = json.Unmarshal([]byte(listResult.ForLLM), &listResp)
+ require.NoError(t, err)
+ require.Len(t, listResp.Sessions, 1)
+ require.Equal(t, resp.SessionID, listResp.Sessions[0].ID)
+
+ killResult := tool.Execute(ctx, map[string]any{
+ "action": "kill",
+ "sessionId": resp.SessionID,
+ })
+ require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM)
+}
+
+func TestShellTool_Read_Output(t *testing.T) {
+ tool, err := NewExecTool("", false)
+ require.NoError(t, err)
+
+ sm := NewSessionManager()
+ tool.sessionManager = sm
+
+ ctx := WithToolContext(context.Background(), "cli", "test")
+
+ runResult := tool.Execute(ctx, map[string]any{
+ "action": "run",
+ "command": "echo hello",
+ "background": "true",
+ })
+ require.False(t, runResult.IsError)
+
+ var resp ExecResponse
+ err = json.Unmarshal([]byte(runResult.ForLLM), &resp)
+ require.NoError(t, err)
+
+ time.Sleep(200 * time.Millisecond)
+
+ readResult := tool.Execute(ctx, map[string]any{
+ "action": "read",
+ "sessionId": resp.SessionID,
+ })
+
+ if !readResult.IsError {
+ var readResp ExecResponse
+ err = json.Unmarshal([]byte(readResult.ForLLM), &readResp)
+ require.NoError(t, err)
+ }
+}
+
+func TestShellTool_Kill(t *testing.T) {
+ tool, err := NewExecTool("", false)
+ require.NoError(t, err)
+
+ sm := NewSessionManager()
+ tool.sessionManager = sm
+
+ ctx := WithToolContext(context.Background(), "cli", "test")
+
+ runResult := tool.Execute(ctx, map[string]any{
+ "action": "run",
+ "command": "sleep 100",
+ "background": "true",
+ })
+ require.False(t, runResult.IsError)
+
+ var resp ExecResponse
+ err = json.Unmarshal([]byte(runResult.ForLLM), &resp)
+ require.NoError(t, err)
+
+ killResult := tool.Execute(ctx, map[string]any{
+ "action": "kill",
+ "sessionId": resp.SessionID,
+ })
+ require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM)
+
+ time.Sleep(100 * time.Millisecond)
+
+ listResult := tool.Execute(ctx, map[string]any{"action": "list"})
+ var listResp ExecResponse
+ err = json.Unmarshal([]byte(listResult.ForLLM), &listResp)
+ require.NoError(t, err)
+ require.Len(t, listResp.Sessions, 0)
+}
+
+func TestShellTool_PTY_AllowedCommands(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("PTY not supported on Windows")
+ }
+
+ tool, err := NewExecTool("", false)
+ require.NoError(t, err)
+
+ sm := NewSessionManager()
+ tool.sessionManager = sm
+
+ ctx := WithToolContext(context.Background(), "cli", "test")
+
+ // Test that PTY is allowed for non-interpreter commands
+ result := tool.Execute(ctx, map[string]any{
+ "action": "run",
+ "command": "cat",
+ "pty": "true",
+ "background": "true",
+ })
+ require.False(t, result.IsError, "PTY with cat should succeed: %s", result.ForLLM)
+ require.Contains(t, result.ForLLM, "sessionId")
+
+ var resp ExecResponse
+ err = json.Unmarshal([]byte(result.ForLLM), &resp)
+ require.NoError(t, err)
+ require.NotEmpty(t, resp.SessionID)
+
+ // Clean up
+ tool.Execute(ctx, map[string]any{
+ "action": "kill",
+ "sessionId": resp.SessionID,
+ })
+}
+
+func TestShellTool_PTY_WriteRead(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("PTY not supported on Windows")
+ }
+
+ tool, err := NewExecTool("", false)
+ require.NoError(t, err)
+
+ sm := NewSessionManager()
+ tool.sessionManager = sm
+
+ ctx := WithToolContext(context.Background(), "cli", "test")
+
+ // Start a PTY session with a command that waits for input
+ // Using 'cat' which will wait for stdin
+ result := tool.Execute(ctx, map[string]any{
+ "action": "run",
+ "command": "cat",
+ "pty": "true",
+ "background": "true",
+ })
+ require.False(t, result.IsError, "PTY run should succeed: %s", result.ForLLM)
+
+ var resp ExecResponse
+ err = json.Unmarshal([]byte(result.ForLLM), &resp)
+ require.NoError(t, err)
+
+ // Write some input to cat
+ writeResult := tool.Execute(ctx, map[string]any{
+ "action": "write",
+ "sessionId": resp.SessionID,
+ "data": "hello\n",
+ })
+ require.False(t, writeResult.IsError, "write should succeed: %s", writeResult.ForLLM)
+
+ // Give cat time to process and output
+ time.Sleep(200 * time.Millisecond)
+
+ // Read the output
+ readResult := tool.Execute(ctx, map[string]any{
+ "action": "read",
+ "sessionId": resp.SessionID,
+ })
+
+ require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM)
+
+ var readResp ExecResponse
+ err = json.Unmarshal([]byte(readResult.ForLLM), &readResp)
+ require.NoError(t, err)
+ // PTY output should contain "hello"
+ require.Contains(t, readResp.Output, "hello")
+
+ // Clean up
+ tool.Execute(ctx, map[string]any{
+ "action": "kill",
+ "sessionId": resp.SessionID,
+ })
+}
+
+func TestShellTool_PTY_Poll(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("PTY not supported on Windows")
+ }
+
+ tool, err := NewExecTool("", false)
+ require.NoError(t, err)
+
+ sm := NewSessionManager()
+ tool.sessionManager = sm
+
+ ctx := WithToolContext(context.Background(), "cli", "test")
+
+ // Start a PTY session with a long-running command
+ result := tool.Execute(ctx, map[string]any{
+ "action": "run",
+ "command": "sleep 2",
+ "pty": "true",
+ "background": "true",
+ })
+ require.False(t, result.IsError, "PTY run should succeed: %s", result.ForLLM)
+
+ var resp ExecResponse
+ err = json.Unmarshal([]byte(result.ForLLM), &resp)
+ require.NoError(t, err)
+
+ // Poll should show running
+ pollResult := tool.Execute(ctx, map[string]any{
+ "action": "poll",
+ "sessionId": resp.SessionID,
+ })
+ require.False(t, pollResult.IsError, "poll should succeed: %s", pollResult.ForLLM)
+
+ var pollResp ExecResponse
+ err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp)
+ require.NoError(t, err)
+ require.Equal(t, "running", pollResp.Status)
+
+ // Wait for sleep to complete
+ time.Sleep(2500 * time.Millisecond)
+
+ // Poll should show done
+ pollResult = tool.Execute(ctx, map[string]any{
+ "action": "poll",
+ "sessionId": resp.SessionID,
+ })
+ require.False(t, pollResult.IsError)
+
+ err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp)
+ require.NoError(t, err)
+ require.Equal(t, "done", pollResp.Status)
+}
+
+func TestShellTool_PTY_Kill(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("PTY not supported on Windows")
+ }
+
+ tool, err := NewExecTool("", false)
+ require.NoError(t, err)
+
+ sm := NewSessionManager()
+ tool.sessionManager = sm
+
+ ctx := WithToolContext(context.Background(), "cli", "test")
+
+ // Start a PTY session with a long-running command
+ result := tool.Execute(ctx, map[string]any{
+ "action": "run",
+ "command": "sleep 10",
+ "pty": "true",
+ "background": "true",
+ })
+ require.False(t, result.IsError, "PTY run should succeed: %s", result.ForLLM)
+
+ var resp ExecResponse
+ err = json.Unmarshal([]byte(result.ForLLM), &resp)
+ require.NoError(t, err)
+
+ // Kill the session
+ killResult := tool.Execute(ctx, map[string]any{
+ "action": "kill",
+ "sessionId": resp.SessionID,
+ })
+ require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM)
+
+ // Verify kill response shows done status
+ var killResp ExecResponse
+ err = json.Unmarshal([]byte(killResult.ForLLM), &killResp)
+ require.NoError(t, err)
+ require.Equal(t, "done", killResp.Status)
+
+ // Poll should return error since session is removed after kill
+ pollResult := tool.Execute(ctx, map[string]any{
+ "action": "poll",
+ "sessionId": resp.SessionID,
+ })
+ // Session is removed after kill, so poll returns error with "session not found"
+ require.True(t, pollResult.IsError, "poll should error after kill (session removed)")
+ require.Contains(t, pollResult.ForLLM, "session not found")
+}
+
+func TestShellTool_Write_Read_NonPTY(t *testing.T) {
+ tool, err := NewExecTool("", false)
+ require.NoError(t, err)
+
+ sm := NewSessionManager()
+ tool.sessionManager = sm
+
+ ctx := WithToolContext(context.Background(), "cli", "test")
+
+ // Start a background process that reads from stdin and outputs it
+ // Using 'cat' which echoes stdin to stdout
+ result := tool.Execute(ctx, map[string]any{
+ "action": "run",
+ "command": "cat",
+ "pty": false,
+ "background": "true",
+ })
+ require.False(t, result.IsError, "run should succeed: %s", result.ForLLM)
+
+ var resp ExecResponse
+ err = json.Unmarshal([]byte(result.ForLLM), &resp)
+ require.NoError(t, err)
+
+ // Write some input to cat
+ writeResult := tool.Execute(ctx, map[string]any{
+ "action": "write",
+ "sessionId": resp.SessionID,
+ "data": "hello world\n",
+ })
+ require.False(t, writeResult.IsError, "write should succeed: %s", writeResult.ForLLM)
+
+ // Give cat time to process and output
+ time.Sleep(200 * time.Millisecond)
+
+ // Read the output
+ readResult := tool.Execute(ctx, map[string]any{
+ "action": "read",
+ "sessionId": resp.SessionID,
+ })
+ require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM)
+
+ var readResp ExecResponse
+ err = json.Unmarshal([]byte(readResult.ForLLM), &readResp)
+ require.NoError(t, err)
+ require.Contains(t, readResp.Output, "hello world")
+
+ // Clean up
+ tool.Execute(ctx, map[string]any{
+ "action": "kill",
+ "sessionId": resp.SessionID,
+ })
+}
+
+func TestShellTool_Read_NonPTY_Running(t *testing.T) {
+ tool, err := NewExecTool("", false)
+ require.NoError(t, err)
+
+ sm := NewSessionManager()
+ tool.sessionManager = sm
+
+ ctx := WithToolContext(context.Background(), "cli", "test")
+
+ // Start a long-running process that produces output over time
+ // Using sh -c with sleep at the end so process doesn't exit immediately
+ result := tool.Execute(ctx, map[string]any{
+ "action": "run",
+ "command": "sh -c 'echo line1; sleep 0.5; echo line2; sleep 0.5; echo line3; sleep 10'",
+ "pty": false,
+ "background": "true",
+ })
+ require.False(t, result.IsError, "run should succeed: %s", result.ForLLM)
+
+ var resp ExecResponse
+ err = json.Unmarshal([]byte(result.ForLLM), &resp)
+ require.NoError(t, err)
+
+ // Give time for first outputs to be produced
+ time.Sleep(300 * time.Millisecond)
+
+ // Read output while process is running
+ readResult := tool.Execute(ctx, map[string]any{
+ "action": "read",
+ "sessionId": resp.SessionID,
+ })
+ require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM)
+
+ var readResp ExecResponse
+ err = json.Unmarshal([]byte(readResult.ForLLM), &readResp)
+ require.NoError(t, err)
+ // Should have at least line1
+ require.Contains(t, readResp.Output, "line1")
+
+ // Wait for line3 to be produced (line1=0s, line2=0.5s, line3=1s, then sleep 10)
+ time.Sleep(1200 * time.Millisecond)
+
+ // Read again - should have line3 as well
+ readResult = tool.Execute(ctx, map[string]any{
+ "action": "read",
+ "sessionId": resp.SessionID,
+ })
+ require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM)
+
+ err = json.Unmarshal([]byte(readResult.ForLLM), &readResp)
+ require.NoError(t, err)
+ require.Contains(t, readResp.Output, "line3")
+
+ // Clean up
+ tool.Execute(ctx, map[string]any{
+ "action": "kill",
+ "sessionId": resp.SessionID,
+ })
+}
+
+func TestShellTool_ProcessGroupKill(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("Process group kill not supported on Windows")
+ }
+
+ // Note: Testing process group kill with PTY is tricky because the command
+ // must be run through an interpreter (sh, bash) which is blocked for PTY.
+ // Instead, we test with non-PTY mode which also uses Setsid for background processes.
+
+ tool, err := NewExecTool("", false)
+ require.NoError(t, err)
+
+ sm := NewSessionManager()
+ tool.sessionManager = sm
+
+ ctx := WithToolContext(context.Background(), "cli", "test")
+
+ // Start a shell that spawns child processes (non-PTY mode)
+ // The sh -c command creates child sleep processes
+ result := tool.Execute(ctx, map[string]any{
+ "action": "run",
+ "command": "sh -c 'sleep 30 & sleep 30 & wait'",
+ "pty": false,
+ "background": "true",
+ })
+ require.False(t, result.IsError, "run should succeed: %s", result.ForLLM)
+
+ var resp ExecResponse
+ err = json.Unmarshal([]byte(result.ForLLM), &resp)
+ require.NoError(t, err)
+
+ // Give time for child processes to spawn
+ time.Sleep(500 * time.Millisecond)
+
+ // Kill the session - should kill the entire process group
+ killResult := tool.Execute(ctx, map[string]any{
+ "action": "kill",
+ "sessionId": resp.SessionID,
+ })
+ require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM)
+
+ // Verify kill response shows done status
+ var killResp ExecResponse
+ err = json.Unmarshal([]byte(killResult.ForLLM), &killResp)
+ require.NoError(t, err)
+ require.Equal(t, "done", killResp.Status)
+
+ // Poll should return error since session is removed after kill
+ pollResult := tool.Execute(ctx, map[string]any{
+ "action": "poll",
+ "sessionId": resp.SessionID,
+ })
+ require.True(t, pollResult.IsError, "poll should error after kill (session removed)")
+ require.Contains(t, pollResult.ForLLM, "session not found")
+}
+
+func TestShellTool_PTY_ProcessGroupKill(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("PTY process group kill not supported on Windows")
+ }
+
+ // This test binary creates 4 child sleep processes and waits for signals.
+ // It's not an interpreter, so it's allowed with PTY mode.
+ // The binary is created in /tmp/test_pgroup.c and compiled as part of test setup.
+ testBinary := "/tmp/test_pgroup"
+ if _, err := os.Stat(testBinary); os.IsNotExist(err) {
+ t.Skip("Test binary /tmp/test_pgroup not found - run: gcc -o /tmp/test_pgroup /tmp/test_pgroup.c")
+ }
+
+ tool, err := NewExecTool("", false)
+ require.NoError(t, err)
+
+ sm := NewSessionManager()
+ tool.sessionManager = sm
+
+ ctx := WithToolContext(context.Background(), "cli", "test")
+
+ // Start the test binary with PTY mode
+ // It forks 4 child sleep processes and waits for signals
+ result := tool.Execute(ctx, map[string]any{
+ "action": "run",
+ "command": testBinary,
+ "pty": "true",
+ "background": "true",
+ })
+ require.False(t, result.IsError, "run should succeed: %s", result.ForLLM)
+
+ var resp ExecResponse
+ err = json.Unmarshal([]byte(result.ForLLM), &resp)
+ require.NoError(t, err)
+
+ // Give time for child processes to spawn
+ time.Sleep(500 * time.Millisecond)
+
+ // Kill the session - should kill the entire process group
+ killResult := tool.Execute(ctx, map[string]any{
+ "action": "kill",
+ "sessionId": resp.SessionID,
+ })
+ require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM)
+
+ // Verify kill response shows done status
+ var killResp ExecResponse
+ err = json.Unmarshal([]byte(killResult.ForLLM), &killResp)
+ require.NoError(t, err)
+ require.Equal(t, "done", killResp.Status)
+
+ // Poll should return error since session is removed after kill
+ pollResult := tool.Execute(ctx, map[string]any{
+ "action": "poll",
+ "sessionId": resp.SessionID,
+ })
+ require.True(t, pollResult.IsError, "poll should error after kill (session removed)")
+ require.Contains(t, pollResult.ForLLM, "session not found")
+}
+
+func TestShellTool_PTY_Background_Read(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("PTY not supported on Windows")
+ }
+
+ tool, err := NewExecTool("", false)
+ require.NoError(t, err)
+
+ sm := NewSessionManager()
+ tool.sessionManager = sm
+
+ ctx := WithToolContext(context.Background(), "cli", "test")
+
+ // Start a fast command with PTY + background mode
+ runResult := tool.Execute(ctx, map[string]any{
+ "action": "run",
+ "command": "echo hello",
+ "pty": "true",
+ "background": "true",
+ })
+ require.False(t, runResult.IsError, "run should succeed: %s", runResult.ForLLM)
+
+ var runResp ExecResponse
+ err = json.Unmarshal([]byte(runResult.ForLLM), &runResp)
+ require.NoError(t, err)
+ require.NotEmpty(t, runResp.SessionID)
+ require.Equal(t, "running", runResp.Status)
+
+ // Wait for command to complete
+ time.Sleep(500 * time.Millisecond)
+
+ // Read output - this is the key test: PTY + background mode should preserve output
+ readResult := tool.Execute(ctx, map[string]any{
+ "action": "read",
+ "sessionId": runResp.SessionID,
+ })
+ require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM)
+ require.Contains(t, readResult.ForLLM, "hello", "output should contain 'hello'")
+}
+
+func TestShellTool_PTY_Background_ReadNoBlock(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("PTY not supported on Windows")
+ }
+
+ tool, err := NewExecTool("", false)
+ require.NoError(t, err)
+
+ sm := NewSessionManager()
+ tool.sessionManager = sm
+
+ ctx := WithToolContext(context.Background(), "cli", "test")
+
+ // Start a long-running command with PTY + background mode
+ // This command produces no output, just sleeps
+ runResult := tool.Execute(ctx, map[string]any{
+ "action": "run",
+ "command": "sleep 10",
+ "pty": "true",
+ "background": "true",
+ })
+ require.False(t, runResult.IsError, "run should succeed: %s", runResult.ForLLM)
+
+ var runResp ExecResponse
+ err = json.Unmarshal([]byte(runResult.ForLLM), &runResp)
+ require.NoError(t, err)
+ require.NotEmpty(t, runResp.SessionID)
+
+ // Read immediately - should NOT block even though process is running and has no output
+ // This tests that Read() returns quickly (within 1 second) instead of blocking for 10 seconds
+ start := time.Now()
+ readResult := tool.Execute(ctx, map[string]any{
+ "action": "read",
+ "sessionId": runResp.SessionID,
+ })
+ elapsed := time.Since(start)
+
+ require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM)
+ require.Less(t, elapsed.Seconds(), 1.0, "read should not block, should return within 1 second")
+
+ // Kill the session to clean up
+ killResult := tool.Execute(ctx, map[string]any{
+ "action": "kill",
+ "sessionId": runResp.SessionID,
+ })
+ require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM)
+}
+
+func TestShellTool_Poll_Status(t *testing.T) {
+ tool, err := NewExecTool("", false)
+ require.NoError(t, err)
+
+ sm := NewSessionManager()
+ tool.sessionManager = sm
+
+ ctx := WithToolContext(context.Background(), "cli", "test")
+
+ runResult := tool.Execute(ctx, map[string]any{
+ "action": "run",
+ "command": "sleep 1",
+ "background": "true",
+ })
+ require.False(t, runResult.IsError)
+
+ var resp ExecResponse
+ err = json.Unmarshal([]byte(runResult.ForLLM), &resp)
+ require.NoError(t, err)
+
+ pollResult := tool.Execute(ctx, map[string]any{
+ "action": "poll",
+ "sessionId": resp.SessionID,
+ })
+ require.False(t, pollResult.IsError)
+
+ var pollResp ExecResponse
+ err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp)
+ require.NoError(t, err)
+ require.Equal(t, "running", pollResp.Status)
+
+ time.Sleep(1200 * time.Millisecond)
+
+ pollResult = tool.Execute(ctx, map[string]any{
+ "action": "poll",
+ "sessionId": resp.SessionID,
+ })
+ require.False(t, pollResult.IsError)
+
+ err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp)
+ require.NoError(t, err)
+ require.Equal(t, "done", pollResp.Status)
+}
+
+func TestShellTool_Action_Run_Sync(t *testing.T) {
+ tool, err := NewExecTool("", false)
+ require.NoError(t, err)
+
+ ctx := context.Background()
+
+ result := tool.Execute(ctx, map[string]any{
+ "action": "run",
+ "command": "echo hello",
+ })
+
+ require.False(t, result.IsError)
+ require.Contains(t, result.ForLLM, "hello")
+}
+
+// TestShellTool_Background_ReadAfterExit verifies that we can read
+// buffered output even after the background process has exited.
+func TestShellTool_Background_ReadAfterExit(t *testing.T) {
+ tool, err := NewExecTool("", false)
+ require.NoError(t, err)
+
+ ctx := context.Background()
+
+ // Start a background command that produces output and exits quickly
+ runResult := tool.Execute(ctx, map[string]any{
+ "action": "run",
+ "command": "echo hello && sleep 1 && echo world",
+ "background": "true",
+ })
+ require.False(t, runResult.IsError, "run should succeed: %s", runResult.ForUser)
+
+ // Parse session ID from response
+ var resp ExecResponse
+ err = json.Unmarshal([]byte(runResult.ForLLM), &resp)
+ require.NoError(t, err)
+ require.NotEmpty(t, resp.SessionID)
+ sessionID := resp.SessionID
+
+ // Wait for process to exit (sleep 1 + some buffer)
+ time.Sleep(1500 * time.Millisecond)
+
+ // Poll to verify process is done
+ pollResult := tool.Execute(ctx, map[string]any{
+ "action": "poll",
+ "sessionId": sessionID,
+ })
+ require.False(t, pollResult.IsError, "poll should succeed: %s", pollResult.ForLLM)
+ var pollResp ExecResponse
+ err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp)
+ require.NoError(t, err)
+ require.Equal(t, "done", pollResp.Status, "process should be done")
+
+ // Try to read output AFTER process has exited
+ readResult := tool.Execute(ctx, map[string]any{
+ "action": "read",
+ "sessionId": sessionID,
+ })
+ require.False(t, readResult.IsError, "read should succeed after exit: %s", readResult.ForLLM)
+
+ var readResp ExecResponse
+ err = json.Unmarshal([]byte(readResult.ForLLM), &readResp)
+ require.NoError(t, err)
+
+ // Output should contain both "hello" and "world"
+ require.Contains(t, readResp.Output, "hello", "should contain hello")
+ require.Contains(t, readResp.Output, "world", "should contain world after sleep")
+}
+
+func TestSendKeys_CtrlC(t *testing.T) {
+ // Note: Ctrl-C as a signal requires sending SIGINT to the process group,
+ // which requires elevated privileges. Writing "\x03" to PTY passes the byte
+ // to the process but doesn't generate SIGINT for processes that don't read stdin.
+ // For interrupting processes, use the kill action instead.
+ t.Skip("Ctrl-C as signal not supported - use kill action for interruption")
+}
+
+func TestEncodeKeyToken(t *testing.T) {
+ tests := []struct {
+ token string
+ expected string
+ hasError bool
+ }{
+ // Named keys
+ {"enter", "\r", false},
+ {"return", "\r", false},
+ {"tab", "\t", false},
+ {"escape", "\x1b", false},
+ {"esc", "\x1b", false},
+ {"backspace", "\x7f", false},
+ {"up", "\x1b[A", false},
+ {"down", "\x1b[B", false},
+ {"left", "\x1b[D", false},
+ {"right", "\x1b[C", false},
+ {"home", "\x1b[1~", false},
+ {"end", "\x1b[4~", false},
+ {"pageup", "\x1b[5~", false},
+ {"pagedown", "\x1b[6~", false},
+ {"delete", "\x1b[3~", false},
+ {"f1", "\x1bOP", false},
+ {"f12", "\x1b[24~", false},
+
+ // Ctrl keys
+ {"ctrl-c", "\x03", false},
+ {"ctrl-d", "\x04", false},
+ {"ctrl-a", "\x01", false},
+ {"ctrl-z", "\x1a", false},
+ {"c-c", "\x03", false},
+ {"c-d", "\x04", false},
+
+ // Alt keys
+ {"alt-x", "\x1bx", false},
+ {"m-x", "\x1bx", false},
+
+ // Case insensitive tests
+ {"ENTER", "\r", false},
+ {"TAB", "\t", false},
+ {"CTRL-C", "\x03", false},
+ {"Ctrl-D", "\x04", false},
+ {"ALT-X", "\x1bx", false},
+ {"M-X", "\x1bx", false},
+ {"UP", "\x1b[A", false},
+ {"DOWN", "\x1b[B", false},
+
+ // Unknown keys should return error (use write action for text input)
+ {"unknown-key", "", true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.token, func(t *testing.T) {
+ result, err := encodeKeyToken(tt.token, PtyKeyModeCSI)
+ if tt.hasError {
+ require.Error(t, err, "expected error for %s", tt.token)
+ } else {
+ require.NoError(t, err, "unexpected error for %s", tt.token)
+ require.Equal(t, tt.expected, result, "wrong encoding for %s", tt.token)
+ }
+ })
+ }
+}
+
+// TestDetectPtyKeyMode tests smkx/rmkx detection in PTY output
+func TestDetectPtyKeyMode(t *testing.T) {
+ tests := []struct {
+ name string
+ raw string
+ expected PtyKeyMode
+ }{
+ {"no toggle", "hello world", PtyKeyModeNotFound},
+ {"smkx only", "\x1b[?1h\x1b=", PtyKeyModeSS3},
+ {"rmkx only", "\x1b[?1l\x1b>", PtyKeyModeCSI},
+ {"both smkx first", "\x1b[?1h\x1b=...\x1b[?1l\x1b>", PtyKeyModeCSI},
+ {"both rmkx first", "\x1b[?1l\x1b>...\x1b[?1h\x1b=", PtyKeyModeSS3},
+ {"multiple toggles smkx last", "\x1b[?1h\x1b=...\x1b[?1l\x1b>...\x1b[?1h\x1b=", PtyKeyModeSS3},
+ {"multiple toggles rmkx last", "\x1b[?1l\x1b>...\x1b[?1h\x1b=...\x1b[?1l\x1b>", PtyKeyModeCSI},
+ {"partial smkx", "\x1b[?1h", PtyKeyModeSS3},
+ {"partial rmkx", "\x1b[?1l", PtyKeyModeCSI},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := detectPtyKeyMode(tt.raw)
+ require.Equal(t, tt.expected, result, "wrong mode for %s", tt.name)
+ })
+ }
+}
+
+func TestEncodeKeyTokenWithPtyKeyMode(t *testing.T) {
+ tests := []struct {
+ name string
+ token string
+ mode PtyKeyMode
+ expected string
+ hasError bool
+ }{
+ // CSI mode
+ {"up csi", "up", PtyKeyModeCSI, "\x1b[A", false},
+ {"down csi", "down", PtyKeyModeCSI, "\x1b[B", false},
+ {"left csi", "left", PtyKeyModeCSI, "\x1b[D", false},
+ {"right csi", "right", PtyKeyModeCSI, "\x1b[C", false},
+
+ // SS3 mode
+ {"up ss3", "up", PtyKeyModeSS3, "\x1bOA", false},
+ {"down ss3", "down", PtyKeyModeSS3, "\x1bOB", false},
+ {"left ss3", "left", PtyKeyModeSS3, "\x1bOD", false},
+ {"right ss3", "right", PtyKeyModeSS3, "\x1bOC", false},
+ {"home ss3", "home", PtyKeyModeSS3, "\x1bOH", false},
+ {"end ss3", "end", PtyKeyModeSS3, "\x1bOF", false},
+
+ // Other keys unaffected by mode
+ {"enter ss3", "enter", PtyKeyModeSS3, "\r", false},
+ {"tab ss3", "tab", PtyKeyModeSS3, "\t", false},
+ {"ctrl-c ss3", "ctrl-c", PtyKeyModeSS3, "\x03", false},
+
+ // NotFound behaves like CSI
+ {"up notfound", "up", PtyKeyModeNotFound, "\x1b[A", false},
+ {"down notfound", "down", PtyKeyModeNotFound, "\x1b[B", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := encodeKeyToken(tt.token, tt.mode)
+ if tt.hasError {
+ require.Error(t, err, "expected error for %s", tt.name)
+ } else {
+ require.NoError(t, err, "unexpected error for %s", tt.name)
+ require.Equal(t, tt.expected, result, "wrong encoding for %s", tt.name)
+ }
+ })
+ }
+}
diff --git a/pkg/tools/shell_timeout_unix_test.go b/pkg/tools/shell_timeout_unix_test.go
index 357e1276e..dfd28454c 100644
--- a/pkg/tools/shell_timeout_unix_test.go
+++ b/pkg/tools/shell_timeout_unix_test.go
@@ -30,6 +30,7 @@ func TestShellTool_TimeoutKillsChildProcess(t *testing.T) {
tool.SetTimeout(500 * time.Millisecond)
args := map[string]any{
+ "action": "run",
// Spawn a child process that would outlive the shell unless process-group kill is used.
"command": "sleep 60 & echo $! > child.pid; wait",
}
diff --git a/pkg/tools/types.go b/pkg/tools/types.go
index a6015cde3..4d1a18d5a 100644
--- a/pkg/tools/types.go
+++ b/pkg/tools/types.go
@@ -56,3 +56,24 @@ type ToolFunctionDefinition struct {
Description string `json:"description"`
Parameters map[string]any `json:"parameters"`
}
+
+type ExecRequest struct {
+ Action string `json:"action"`
+ Command string `json:"command,omitempty"`
+ PTY bool `json:"pty,omitempty"`
+ Background bool `json:"background,omitempty"`
+ Timeout int `json:"timeout,omitempty"`
+ Env map[string]string `json:"env,omitempty"`
+ Cwd string `json:"cwd,omitempty"`
+ SessionID string `json:"sessionId,omitempty"`
+ Data string `json:"data,omitempty"`
+}
+
+type ExecResponse struct {
+ SessionID string `json:"sessionId,omitempty"`
+ Status string `json:"status,omitempty"`
+ ExitCode int `json:"exitCode,omitempty"`
+ Output string `json:"output,omitempty"`
+ Error string `json:"error,omitempty"`
+ Sessions []SessionInfo `json:"sessions,omitempty"`
+}
From ebcd5645f1e0cebace2ec48f9ad3aa7b348c4970 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?daming=E5=A4=A7=E9=93=AD?=
Date: Sun, 22 Mar 2026 00:39:47 +0800
Subject: [PATCH 44/44] =?UTF-8?q?Revert=20"feat(tools):=20add=20exec=20too?=
=?UTF-8?q?l=20enhancement=20with=20background=20execution=20and=20?=
=?UTF-8?q?=E2=80=A6"?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This reverts commit f901af8cbc2d331fb500d24bf8b68a746538c4d9.
---
go.mod | 3 +-
go.sum | 1 -
pkg/agent/instance_test.go | 5 +-
pkg/tools/session.go | 252 -------
pkg/tools/session_process_unix.go | 14 -
pkg/tools/session_process_windows.go | 13 -
pkg/tools/session_test.go | 99 ---
pkg/tools/shell.go | 742 +--------------------
pkg/tools/shell_test.go | 962 +--------------------------
pkg/tools/shell_timeout_unix_test.go | 1 -
pkg/tools/types.go | 21 -
11 files changed, 31 insertions(+), 2082 deletions(-)
delete mode 100644 pkg/tools/session.go
delete mode 100644 pkg/tools/session_process_unix.go
delete mode 100644 pkg/tools/session_process_windows.go
delete mode 100644 pkg/tools/session_test.go
diff --git a/go.mod b/go.mod
index 744e05e17..cfc930d37 100644
--- a/go.mod
+++ b/go.mod
@@ -3,13 +3,12 @@ module github.com/sipeed/picoclaw
go 1.25.8
require (
- fyne.io/systray v1.12.0
github.com/BurntSushi/toml v1.6.0
+ fyne.io/systray v1.12.0
github.com/adhocore/gronx v1.19.6
github.com/anthropics/anthropic-sdk-go v1.26.0
github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.4.0
- github.com/creack/pty v1.1.9
github.com/ergochat/irc-go v0.6.0
github.com/ergochat/readline v0.1.3
github.com/gdamore/tcell/v2 v2.13.8
diff --git a/go.sum b/go.sum
index dc82d46ef..f24b997d4 100644
--- a/go.sum
+++ b/go.sum
@@ -37,7 +37,6 @@ github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
-github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go
index 84cfa81df..b3318ad1f 100644
--- a/pkg/agent/instance_test.go
+++ b/pkg/agent/instance_test.go
@@ -236,9 +236,8 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
t.Fatal("exec tool not registered")
}
execResult := execTool.Execute(context.Background(), map[string]any{
- "action": "run",
- "command": "cat " + filepath.Base(mediaPath),
- "cwd": mediaDir,
+ "command": "cat " + filepath.Base(mediaPath),
+ "working_dir": mediaDir,
})
if execResult.IsError {
t.Fatalf("exec should allow media temp dir, got: %s", execResult.ForLLM)
diff --git a/pkg/tools/session.go b/pkg/tools/session.go
deleted file mode 100644
index e32bc3ddf..000000000
--- a/pkg/tools/session.go
+++ /dev/null
@@ -1,252 +0,0 @@
-package tools
-
-import (
- "bytes"
- "errors"
- "io"
- "os"
- "sync"
- "time"
-
- "github.com/google/uuid"
-)
-
-const maxOutputBufferSize = 100 * 1024 * 1024 // 100MB
-
-const outputTruncateMarker = "\n... [output truncated, exceeded 100MB]\n"
-
-// PtyKeyMode represents arrow key encoding mode for PTY sessions.
-// Programs send smkx/rmkx sequences to switch between CSI and SS3 modes.
-type PtyKeyMode uint8
-
-const (
- PtyKeyModeCSI PtyKeyMode = iota // triggered by rmkx (\x1b[?1l)
- PtyKeyModeSS3 // triggered by smkx (\x1b[?1h)
-)
-
-const PtyKeyModeNotFound PtyKeyMode = 255
-
-var (
- ErrSessionNotFound = errors.New("session not found")
- ErrSessionDone = errors.New("session already completed")
- ErrPTYNotSupported = errors.New("PTY is not supported on this platform")
- ErrNoStdin = errors.New("no stdin available")
-)
-
-type ProcessSession struct {
- mu sync.Mutex
- ID string
- PID int
- Command string
- PTY bool
- Background bool
- StartTime int64
- ExitCode int
- Status string
- stdinWriter io.Writer
- stdoutPipe io.Reader
- outputBuffer *bytes.Buffer
- outputTruncated bool
- ptyMaster *os.File
-
- // ptyKeyMode tracks arrow key encoding mode (CSI vs SS3)
- ptyKeyMode PtyKeyMode
-}
-
-func (s *ProcessSession) IsDone() bool {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.Status == "done" || s.Status == "exited"
-}
-
-func (s *ProcessSession) GetPtyKeyMode() PtyKeyMode {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.ptyKeyMode
-}
-
-func (s *ProcessSession) SetPtyKeyMode(mode PtyKeyMode) {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.ptyKeyMode = mode
-}
-
-func (s *ProcessSession) GetStatus() string {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.Status
-}
-
-func (s *ProcessSession) SetStatus(status string) {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.Status = status
-}
-
-func (s *ProcessSession) GetExitCode() int {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.ExitCode
-}
-
-func (s *ProcessSession) SetExitCode(code int) {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.ExitCode = code
-}
-
-func (s *ProcessSession) killProcess() error {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- if s.Status != "running" {
- return ErrSessionDone
- }
-
- pid := s.PID
- if pid <= 0 {
- return ErrSessionNotFound
- }
-
- if err := killProcessGroup(pid); err != nil {
- return err
- }
-
- s.Status = "done"
- s.ExitCode = -1
- return nil
-}
-
-func (s *ProcessSession) Kill() error {
- return s.killProcess()
-}
-
-func (s *ProcessSession) Write(data string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- if s.Status != "running" {
- return ErrSessionDone
- }
-
- var writer io.Writer
- if s.PTY && s.ptyMaster != nil {
- writer = s.ptyMaster
- } else if s.stdinWriter != nil {
- writer = s.stdinWriter
- } else {
- return ErrNoStdin
- }
-
- _, err := writer.Write([]byte(data))
- return err
-}
-
-func (s *ProcessSession) Read() string {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- if s.outputBuffer.Len() == 0 {
- return ""
- }
-
- data := s.outputBuffer.String()
- s.outputBuffer.Reset()
- return data
-}
-
-func (s *ProcessSession) ToSessionInfo() SessionInfo {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- return SessionInfo{
- ID: s.ID,
- Command: s.Command,
- Status: s.Status,
- PID: s.PID,
- StartedAt: s.StartTime,
- }
-}
-
-type SessionManager struct {
- mu sync.RWMutex
- sessions map[string]*ProcessSession
-}
-
-func NewSessionManager() *SessionManager {
- sm := &SessionManager{
- sessions: make(map[string]*ProcessSession),
- }
-
- // Start cleaner goroutine - runs every 5 minutes, cleans up sessions done for >30 minutes
- go func() {
- ticker := time.NewTicker(5 * time.Minute)
- defer ticker.Stop()
- for range ticker.C {
- sm.cleanupOldSessions()
- }
- }()
-
- return sm
-}
-
-// cleanupOldSessions removes sessions that are done and older than 30 minutes
-func (sm *SessionManager) cleanupOldSessions() {
- sm.mu.Lock()
- defer sm.mu.Unlock()
-
- cutoff := time.Now().Add(-30 * time.Minute)
- for id, session := range sm.sessions {
- if session.IsDone() && session.StartTime < cutoff.Unix() {
- delete(sm.sessions, id)
- }
- }
-}
-
-func (sm *SessionManager) Add(session *ProcessSession) {
- sm.mu.Lock()
- defer sm.mu.Unlock()
- sm.sessions[session.ID] = session
-}
-
-func (sm *SessionManager) Get(sessionID string) (*ProcessSession, error) {
- sm.mu.RLock()
- defer sm.mu.RUnlock()
-
- session, ok := sm.sessions[sessionID]
- if !ok {
- return nil, ErrSessionNotFound
- }
-
- return session, nil
-}
-
-func (sm *SessionManager) Remove(sessionID string) {
- sm.mu.Lock()
- defer sm.mu.Unlock()
- delete(sm.sessions, sessionID)
-}
-
-func (sm *SessionManager) List() []SessionInfo {
- sm.mu.RLock()
- defer sm.mu.RUnlock()
-
- result := make([]SessionInfo, 0, len(sm.sessions))
- for _, session := range sm.sessions {
- result = append(result, session.ToSessionInfo())
- }
-
- return result
-}
-
-func generateSessionID() string {
- return uuid.New().String()[:8]
-}
-
-type SessionInfo struct {
- ID string `json:"id"`
- Command string `json:"command"`
- Status string `json:"status"`
- PID int `json:"pid"`
- StartedAt int64 `json:"startedAt"`
-}
diff --git a/pkg/tools/session_process_unix.go b/pkg/tools/session_process_unix.go
deleted file mode 100644
index 2fe30166e..000000000
--- a/pkg/tools/session_process_unix.go
+++ /dev/null
@@ -1,14 +0,0 @@
-//go:build !windows
-
-package tools
-
-import (
- "syscall"
-)
-
-func killProcessGroup(pid int) error {
- if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
- _ = syscall.Kill(pid, syscall.SIGKILL)
- }
- return nil
-}
diff --git a/pkg/tools/session_process_windows.go b/pkg/tools/session_process_windows.go
deleted file mode 100644
index 7cf558954..000000000
--- a/pkg/tools/session_process_windows.go
+++ /dev/null
@@ -1,13 +0,0 @@
-//go:build windows
-
-package tools
-
-import (
- "os/exec"
- "strconv"
-)
-
-func killProcessGroup(pid int) error {
- _ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run()
- return nil
-}
diff --git a/pkg/tools/session_test.go b/pkg/tools/session_test.go
deleted file mode 100644
index 6cfe72a10..000000000
--- a/pkg/tools/session_test.go
+++ /dev/null
@@ -1,99 +0,0 @@
-package tools
-
-import (
- "testing"
-
- "github.com/stretchr/testify/require"
-)
-
-func TestSessionManager_AddGet(t *testing.T) {
- sm := NewSessionManager()
- session := &ProcessSession{
- ID: "test-1",
- Command: "echo hello",
- Status: "running",
- StartTime: 1000,
- }
-
- sm.Add(session)
-
- got, err := sm.Get("test-1")
- require.NoError(t, err)
- require.Equal(t, "test-1", got.ID)
-}
-
-func TestSessionManager_Remove(t *testing.T) {
- sm := NewSessionManager()
- session := &ProcessSession{
- ID: "test-1",
- Command: "echo hello",
- Status: "running",
- StartTime: 1000,
- }
- sm.Add(session)
- sm.Remove("test-1")
-
- _, err := sm.Get("test-1")
- require.ErrorIs(t, err, ErrSessionNotFound)
-}
-
-func TestSessionManager_List(t *testing.T) {
- sm := NewSessionManager()
- sm.Add(&ProcessSession{
- ID: "test-1",
- Command: "echo hello",
- Status: "running",
- StartTime: 1000,
- })
- sm.Add(&ProcessSession{
- ID: "test-2",
- Command: "echo world",
- Status: "running",
- StartTime: 1001,
- })
- sm.Add(&ProcessSession{
- ID: "test-3",
- Command: "echo done",
- Status: "done",
- StartTime: 1002,
- })
-
- sessions := sm.List()
- require.Len(t, sessions, 3)
-
- ids := make(map[string]bool)
- for _, s := range sessions {
- ids[s.ID] = true
- }
- require.True(t, ids["test-1"])
- require.True(t, ids["test-2"])
- require.True(t, ids["test-3"])
-}
-
-func TestProcessSession_IsDone(t *testing.T) {
- session := &ProcessSession{Status: "running"}
- require.False(t, session.IsDone())
-
- session.Status = "done"
- require.True(t, session.IsDone())
-
- session.Status = "exited"
- require.True(t, session.IsDone())
-}
-
-func TestProcessSession_ToSessionInfo(t *testing.T) {
- session := &ProcessSession{
- ID: "test-1",
- PID: 12345,
- Command: "echo hello",
- Status: "running",
- StartTime: 1000,
- }
-
- info := session.ToSessionInfo()
- require.Equal(t, "test-1", info.ID)
- require.Equal(t, "echo hello", info.Command)
- require.Equal(t, "running", info.Status)
- require.Equal(t, 12345, info.PID)
- require.Equal(t, int64(1000), info.StartedAt)
-}
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index f3869cc1c..78ad2b26d 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -3,37 +3,20 @@ package tools
import (
"bytes"
"context"
- "encoding/json"
"errors"
"fmt"
- "io"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
- "sync"
- "syscall"
"time"
- "github.com/creack/pty"
-
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
)
-var (
- globalSessionManager = NewSessionManager()
- sessionManagerMu sync.RWMutex
-)
-
-func getSessionManager() *SessionManager {
- sessionManagerMu.RLock()
- defer sessionManagerMu.RUnlock()
- return globalSessionManager
-}
-
type ExecTool struct {
workingDir string
timeout time.Duration
@@ -43,7 +26,6 @@ type ExecTool struct {
allowedPathPatterns []*regexp.Regexp
restrictToWorkspace bool
allowRemote bool
- sessionManager *SessionManager
}
var (
@@ -163,7 +145,7 @@ func NewExecToolWithConfig(
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
}
- var timeout time.Duration
+ timeout := 60 * time.Second
if config != nil && config.Tools.Exec.TimeoutSeconds > 0 {
timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second
}
@@ -177,7 +159,6 @@ func NewExecToolWithConfig(
allowedPathPatterns: allowedPathPatterns,
restrictToWorkspace: restrict,
allowRemote: allowRemote,
- sessionManager: getSessionManager(),
}, nil
}
@@ -186,146 +167,27 @@ func (t *ExecTool) Name() string {
}
func (t *ExecTool) Description() string {
- return `Execute shell commands. Use background=true for long-running commands (returns sessionId). Use pty=true for interactive commands (can combine with background=true). Use poll/read/write/send-keys/kill with sessionId to manage background sessions. Sessions auto-cleanup 30 minutes after process exits; use kill to terminate early. Output buffer limit: 100MB.`
+ return "Execute a shell command and return its output. Use with caution."
}
func (t *ExecTool) Parameters() map[string]any {
return map[string]any{
- "oneOf": []map[string]any{
- {
- "type": "object",
- "properties": map[string]any{
- "action": map[string]any{"const": "run", "description": "Execute a shell command"},
- "command": map[string]any{"type": "string", "description": "Shell command to execute"},
- "background": map[string]any{
- "type": "string",
- "description": "Run in background immediately",
- },
- "pty": map[string]any{
- "type": "string",
- "description": "Run in a pseudo-terminal (PTY) when available",
- },
- "cwd": map[string]any{
- "type": "string",
- "description": "Working directory for the command",
- },
- "timeout": map[string]any{
- "type": "integer",
- "description": "Timeout in seconds (default: 0 = no timeout, kills process on expiry)",
- },
- },
- "required": []string{"action", "command"},
+ "type": "object",
+ "properties": map[string]any{
+ "command": map[string]any{
+ "type": "string",
+ "description": "The shell command to execute",
},
- {
- "type": "object",
- "properties": map[string]any{
- "action": map[string]any{"const": "list", "description": "List all active sessions"},
- },
- "required": []string{"action"},
- },
- {
- "type": "object",
- "properties": map[string]any{
- "action": map[string]any{
- "const": "poll",
- "description": "Check session status. Returns: {sessionId, status: running|done, exitCode}. exitCode only meaningful when status=done",
- },
- "sessionId": map[string]any{
- "type": "string",
- "description": "Session ID returned from background command",
- },
- },
- "required": []string{"action", "sessionId"},
- },
- {
- "type": "object",
- "properties": map[string]any{
- "action": map[string]any{
- "const": "read",
- "description": "Read output from session. Returns: {sessionId, output, status: running|done}",
- },
- "sessionId": map[string]any{
- "type": "string",
- "description": "Session ID returned from background command",
- },
- },
- "required": []string{"action", "sessionId"},
- },
- {
- "type": "object",
- "properties": map[string]any{
- "action": map[string]any{
- "const": "write",
- "description": "Send input to session stdin (only when status=running)",
- },
- "sessionId": map[string]any{
- "type": "string",
- "description": "Session ID returned from background command",
- },
- "data": map[string]any{"type": "string", "description": "Data to write to session stdin."},
- },
- "required": []string{"action", "sessionId", "data"},
- },
- {
- "type": "object",
- "properties": map[string]any{
- "action": map[string]any{"const": "kill", "description": "Terminate session"},
- "sessionId": map[string]any{
- "type": "string",
- "description": "Session ID returned from background command",
- },
- },
- "required": []string{"action", "sessionId"},
- },
- {
- "type": "object",
- "properties": map[string]any{
- "action": map[string]any{
- "const": "send-keys",
- "description": "Send special keys to PTY session. Keys: down/up/left/right/enter/escape/tab/backspace/ctrl-c/ctrl-d/ctrl-z. Multiple keys separated by comma",
- },
- "sessionId": map[string]any{
- "type": "string",
- "description": "Session ID returned from background command",
- },
- "keys": map[string]any{
- "type": "string",
- "description": "Comma-separated key names (optional spaces around comma). Valid keys: up, down, left, right, enter, tab, escape, backspace, ctrl-c, ctrl-d, home, end, pageup, pagedown, f1-f12.",
- },
- },
- "required": []string{"action", "sessionId", "keys"},
+ "working_dir": map[string]any{
+ "type": "string",
+ "description": "Optional working directory for the command",
},
},
+ "required": []string{"command"},
}
}
func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
- action, _ := args["action"].(string)
- if action == "" {
- return ErrorResult("action is required")
- }
-
- switch action {
- case "run":
- return t.executeRun(ctx, args)
- case "list":
- return t.executeList()
- case "poll":
- return t.executePoll(args)
- case "read":
- return t.executeRead(args)
- case "write":
- return t.executeWrite(args)
- case "kill":
- return t.executeKill(args)
- case "send-keys":
- return t.executeSendKeys(args)
- default:
- return ErrorResult(fmt.Sprintf("unknown action: %s", action))
- }
-}
-
-func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolResult {
command, ok := args["command"].(string)
if !ok {
return ErrorResult("command is required")
@@ -344,26 +206,8 @@ func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolRes
}
}
- getBoolArg := func(key string) bool {
- switch v := args[key].(type) {
- case bool:
- return v
- case string:
- return v == "true"
- }
- return false
- }
- isPty := getBoolArg("pty")
- isBackground := getBoolArg("background")
-
- if isPty {
- if runtime.GOOS == "windows" {
- return ErrorResult("PTY is not supported on Windows. Use background=true without pty.")
- }
- }
-
cwd := t.workingDir
- if wd, ok := args["cwd"].(string); ok && wd != "" {
+ if wd, ok := args["working_dir"].(string); ok && wd != "" {
if t.restrictToWorkspace && t.workingDir != "" {
resolvedWD, err := validatePathWithAllowPaths(wd, t.workingDir, true, t.allowedPathPatterns)
if err != nil {
@@ -409,14 +253,6 @@ func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolRes
}
}
- if isBackground {
- return t.runBackground(ctx, command, cwd, isPty)
- }
-
- return t.runSync(ctx, command, cwd)
-}
-
-func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult {
// timeout == 0 means no timeout
var cmdCtx context.Context
var cancel context.CancelFunc
@@ -525,560 +361,6 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult
}
}
-func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEnabled bool) *ToolResult {
- sessionID := generateSessionID()
- session := &ProcessSession{
- ID: sessionID,
- Command: command,
- PTY: ptyEnabled,
- Background: true,
- StartTime: time.Now().Unix(),
- Status: "running",
- ptyKeyMode: PtyKeyModeCSI,
- }
-
- var cmd *exec.Cmd
- if runtime.GOOS == "windows" {
- cmd = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", command)
- } else {
- cmd = exec.Command("sh", "-c", command)
- }
- if cwd != "" {
- cmd.Dir = cwd
- }
-
- prepareCommandForTermination(cmd)
-
- var stdoutReader io.ReadCloser
- var stderrReader io.ReadCloser
- var stdinWriter io.WriteCloser
-
- if ptyEnabled {
- ptmx, tty, err := pty.Open()
- if err != nil {
- return ErrorResult(fmt.Sprintf("failed to create PTY: %v", err))
- }
-
- cmd.Stdin = tty
- cmd.Stdout = tty
- cmd.Stderr = tty
-
- // For PTY, we need Setsid to create a new session.
- // Note: Setsid and Setpgid conflict, so we must replace SysProcAttr entirely.
- cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
-
- session.ptyMaster = ptmx
- } else {
- var err error
- stdoutReader, err = cmd.StdoutPipe()
- if err != nil {
- return ErrorResult(fmt.Sprintf("failed to create stdout pipe: %v", err))
- }
- stderrReader, err = cmd.StderrPipe()
- if err != nil {
- return ErrorResult(fmt.Sprintf("failed to create stderr pipe: %v", err))
- }
- stdinWriter, err = cmd.StdinPipe()
- if err != nil {
- return ErrorResult(fmt.Sprintf("failed to create stdin pipe: %v", err))
- }
- session.stdoutPipe = io.MultiReader(stdoutReader, stderrReader)
- session.stdinWriter = stdinWriter
- }
-
- if err := cmd.Start(); err != nil {
- if session.ptyMaster != nil {
- session.ptyMaster.Close()
- }
- return ErrorResult(fmt.Sprintf("failed to start command: %v", err))
- }
-
- session.PID = cmd.Process.Pid
- t.sessionManager.Add(session)
-
- session.outputBuffer = &bytes.Buffer{}
-
- // PTY mode: read from ptyMaster and wait for process
- // Note: On Linux, closing ptyMaster doesn't interrupt blocking Read() calls,
- // so we need cmd.Wait() in a separate goroutine to detect process exit.
- if session.PTY && session.ptyMaster != nil {
- go func() {
- cmd.Wait() // Wait for process to exit
- session.mu.Lock()
- if cmd.ProcessState != nil {
- session.ExitCode = cmd.ProcessState.ExitCode()
- }
- session.Status = "done"
- session.mu.Unlock()
- }()
-
- go func() {
- buf := make([]byte, 4096)
- for {
- n, err := session.ptyMaster.Read(buf)
- if n > 0 {
- raw := string(buf[:n])
- if mode := detectPtyKeyMode(raw); mode != PtyKeyModeNotFound && mode != session.GetPtyKeyMode() {
- session.SetPtyKeyMode(mode)
- }
-
- session.mu.Lock()
- if session.outputBuffer.Len() >= maxOutputBufferSize {
- if !session.outputTruncated {
- session.outputBuffer.WriteString(outputTruncateMarker)
- session.outputTruncated = true
- }
- } else {
- session.outputBuffer.Write(buf[:n])
- }
- session.mu.Unlock()
- }
- if err != nil {
- break
- }
- }
- }()
- } else {
- // Non-PTY mode: single goroutine reads pipes.
- // When Read() returns EOF (pipe closed), we break.
- // When process exits, OS closes pipe write end → Read() returns EOF → we exit.
- go func() {
- buf := make([]byte, 4096)
-
- // Read stdout
- for {
- n, err := stdoutReader.Read(buf)
- if n > 0 {
- session.mu.Lock()
- if session.outputBuffer.Len() >= maxOutputBufferSize {
- if !session.outputTruncated {
- session.outputBuffer.WriteString(outputTruncateMarker)
- session.outputTruncated = true
- }
- } else {
- session.outputBuffer.Write(buf[:n])
- }
- session.mu.Unlock()
- }
- if err != nil {
- break
- }
- }
-
- // Read stderr
- for {
- n, err := stderrReader.Read(buf)
- if n > 0 {
- session.mu.Lock()
- if session.outputBuffer.Len() >= maxOutputBufferSize {
- if !session.outputTruncated {
- session.outputBuffer.WriteString(outputTruncateMarker)
- session.outputTruncated = true
- }
- } else {
- session.outputBuffer.Write(buf[:n])
- }
- session.mu.Unlock()
- }
- if err != nil {
- break
- }
- }
-
- // All pipes closed, get exit status
- if stdinWriter != nil {
- stdinWriter.Close()
- }
- cmd.Wait()
-
- session.mu.Lock()
- if cmd.ProcessState != nil {
- session.ExitCode = cmd.ProcessState.ExitCode()
- }
- session.Status = "done"
- session.mu.Unlock()
- }()
- }
-
- resp := ExecResponse{
- SessionID: sessionID,
- Status: "running",
- }
- data, _ := json.Marshal(resp)
- return &ToolResult{
- ForLLM: string(data),
- ForUser: fmt.Sprintf("Session %s started", sessionID),
- IsError: false,
- }
-}
-
-func (t *ExecTool) executeList() *ToolResult {
- sessions := t.sessionManager.List()
- resp := ExecResponse{
- Sessions: sessions,
- }
- data, _ := json.Marshal(resp)
- return &ToolResult{
- ForLLM: string(data),
- ForUser: fmt.Sprintf("%d active sessions", len(sessions)),
- IsError: false,
- }
-}
-
-func (t *ExecTool) executePoll(args map[string]any) *ToolResult {
- sessionID, ok := args["sessionId"].(string)
- if !ok {
- return ErrorResult("sessionId is required")
- }
-
- session, err := t.sessionManager.Get(sessionID)
- if err != nil {
- if errors.Is(err, ErrSessionNotFound) {
- return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
- }
- return ErrorResult(err.Error())
- }
-
- resp := ExecResponse{
- SessionID: sessionID,
- Status: session.GetStatus(),
- ExitCode: session.GetExitCode(),
- }
- data, _ := json.Marshal(resp)
- return &ToolResult{
- ForLLM: string(data),
- IsError: false,
- }
-}
-
-func (t *ExecTool) executeRead(args map[string]any) *ToolResult {
- sessionID, ok := args["sessionId"].(string)
- if !ok {
- return ErrorResult("sessionId is required")
- }
-
- session, err := t.sessionManager.Get(sessionID)
- if err != nil {
- if errors.Is(err, ErrSessionNotFound) {
- return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
- }
- return ErrorResult(err.Error())
- }
-
- output := session.Read()
-
- resp := ExecResponse{
- SessionID: sessionID,
- Output: output,
- Status: session.GetStatus(),
- }
- data, _ := json.Marshal(resp)
- return &ToolResult{
- ForLLM: string(data),
- IsError: false,
- }
-}
-
-func (t *ExecTool) executeWrite(args map[string]any) *ToolResult {
- sessionID, ok := args["sessionId"].(string)
- if !ok {
- return ErrorResult("sessionId is required")
- }
-
- data, ok := args["data"].(string)
- if !ok {
- return ErrorResult("data is required")
- }
-
- session, err := t.sessionManager.Get(sessionID)
- if err != nil {
- if errors.Is(err, ErrSessionNotFound) {
- return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
- }
- return ErrorResult(err.Error())
- }
-
- if session.IsDone() {
- return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
- }
-
- if err := session.Write(data); err != nil {
- if errors.Is(err, ErrSessionDone) {
- return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
- }
- return ErrorResult(fmt.Sprintf("failed to write to session: %v", err))
- }
-
- resp := ExecResponse{
- SessionID: sessionID,
- Status: session.GetStatus(),
- }
- respData, _ := json.Marshal(resp)
- return &ToolResult{
- ForLLM: string(respData),
- IsError: false,
- }
-}
-
-func (t *ExecTool) executeKill(args map[string]any) *ToolResult {
- sessionID, ok := args["sessionId"].(string)
- if !ok {
- return ErrorResult("sessionId is required")
- }
-
- session, err := t.sessionManager.Get(sessionID)
- if err != nil {
- if errors.Is(err, ErrSessionNotFound) {
- return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
- }
- return ErrorResult(err.Error())
- }
-
- if session.IsDone() {
- return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
- }
-
- if err := session.Kill(); err != nil {
- return ErrorResult(fmt.Sprintf("failed to kill session: %v", err))
- }
-
- t.sessionManager.Remove(sessionID)
-
- resp := ExecResponse{
- SessionID: sessionID,
- Status: "done",
- }
- data, _ := json.Marshal(resp)
- return &ToolResult{
- ForLLM: string(data),
- ForUser: fmt.Sprintf("Session %s killed", sessionID),
- IsError: false,
- }
-}
-
-// keyMap maps key names to their escape sequences.
-var keyMap = map[string]string{
- "enter": "\r",
- "return": "\r",
- "tab": "\t",
- "escape": "\x1b",
- "esc": "\x1b",
- "space": " ",
- "backspace": "\x7f",
- "bspace": "\x7f",
- "up": "\x1b[A",
- "down": "\x1b[B",
- "right": "\x1b[C",
- "left": "\x1b[D",
- "home": "\x1b[1~",
- "end": "\x1b[4~",
- "pageup": "\x1b[5~",
- "pagedown": "\x1b[6~",
- "pgup": "\x1b[5~",
- "pgdn": "\x1b[6~",
- "insert": "\x1b[2~",
- "ic": "\x1b[2~",
- "delete": "\x1b[3~",
- "del": "\x1b[3~",
- "dc": "\x1b[3~",
- "btab": "\x1b[Z",
- "f1": "\x1bOP",
- "f2": "\x1bOQ",
- "f3": "\x1bOR",
- "f4": "\x1bOS",
- "f5": "\x1b[15~",
- "f6": "\x1b[17~",
- "f7": "\x1b[18~",
- "f8": "\x1b[19~",
- "f9": "\x1b[20~",
- "f10": "\x1b[21~",
- "f11": "\x1b[23~",
- "f12": "\x1b[24~",
-}
-
-// ss3KeysMap maps key names to SS3 escape sequences
-var ss3KeysMap = map[string]string{
- "up": "\x1bOA",
- "down": "\x1bOB",
- "right": "\x1bOC",
- "left": "\x1bOD",
- "home": "\x1bOH",
- "end": "\x1bOF",
-}
-
-func detectPtyKeyMode(raw string) PtyKeyMode {
- const SMKX = "\x1b[?1h"
- const RMKX = "\x1b[?1l"
-
- lastSmkx := strings.LastIndex(raw, SMKX)
- lastRmkx := strings.LastIndex(raw, RMKX)
-
- if lastSmkx == -1 && lastRmkx == -1 {
- return PtyKeyModeNotFound
- }
-
- if lastSmkx > lastRmkx {
- return PtyKeyModeSS3
- }
- return PtyKeyModeCSI
-}
-
-// encodeKeyToken encodes a single key token into its escape sequence.
-// Supports:
-// - Named keys: "enter", "tab", "up", "ctrl-c", "alt-x", etc.
-// - Ctrl modifier: "ctrl-c" or "c-c" (sends Ctrl+char)
-// - Alt modifier: "alt-x" or "m-x" (sends ESC+char)
-func encodeKeyToken(token string, ptyKeyMode PtyKeyMode) (string, error) {
- token = strings.ToLower(strings.TrimSpace(token))
- if token == "" {
- return "", nil
- }
-
- // Handle ctrl-X format (c-x)
- if strings.HasPrefix(token, "c-") {
- char := token[2]
- if char >= 'a' && char <= 'z' {
- return string(rune(char) & 0x1f), nil // ctrl-a through ctrl-z
- }
- return "", fmt.Errorf("invalid ctrl key: %s", token)
- }
-
- // Handle ctrl-X format (ctrl-x)
- if strings.HasPrefix(token, "ctrl-") {
- char := token[5]
- if char >= 'a' && char <= 'z' {
- return string(rune(char) & 0x1f), nil
- }
- return "", fmt.Errorf("invalid ctrl key: %s", token)
- }
-
- // Handle alt-X format (m-x or alt-x)
- if strings.HasPrefix(token, "m-") || strings.HasPrefix(token, "alt-") {
- var char string
- if strings.HasPrefix(token, "m-") {
- char = token[2:]
- } else {
- char = token[4:]
- }
- if len(char) == 1 {
- return "\x1b" + char, nil
- }
- return "", fmt.Errorf("invalid alt key: %s", token)
- }
-
- // Handle shift modifier for special keys (shift-up, shift-down, etc.)
- if strings.HasPrefix(token, "s-") || strings.HasPrefix(token, "shift-") {
- var key string
- if strings.HasPrefix(token, "s-") {
- key = token[2:]
- } else {
- key = token[6:]
- }
- // Apply shift modifier: for single-char keys, return uppercase
- if seq, ok := keyMap[key]; ok {
- // For escape sequences, we can't easily add shift
- // For single-char keys (letters), return uppercase
- if len(seq) == 1 {
- return strings.ToUpper(seq), nil
- }
- return seq, nil
- }
- return "", fmt.Errorf("unknown key with shift: %s", key)
- }
-
- if ptyKeyMode == PtyKeyModeSS3 {
- if seq, ok := ss3KeysMap[token]; ok {
- return seq, nil
- }
- }
-
- if seq, ok := keyMap[token]; ok {
- return seq, nil
- }
-
- return "", fmt.Errorf("unknown key: %s (use write action for text input)", token)
-}
-
-// encodeKeySequence encodes a slice of key tokens into a single string.
-func encodeKeySequence(tokens []string, ptyKeyMode PtyKeyMode) (string, error) {
- var result string
- for _, token := range tokens {
- seq, err := encodeKeyToken(token, ptyKeyMode)
- if err != nil {
- return "", err
- }
- result += seq
- }
- return result, nil
-}
-
-func (t *ExecTool) executeSendKeys(args map[string]any) *ToolResult {
- sessionID, ok := args["sessionId"].(string)
- if !ok {
- return ErrorResult("sessionId is required")
- }
-
- keysStr, ok := args["keys"].(string)
- if !ok {
- return ErrorResult("keys must be a string")
- }
-
- if keysStr == "" {
- return ErrorResult("keys cannot be empty")
- }
-
- // Parse comma-separated key names
- keyNames := strings.Split(keysStr, ",")
- var keys []string
- for _, k := range keyNames {
- k = strings.TrimSpace(k)
- if k != "" {
- keys = append(keys, k)
- }
- }
-
- if len(keys) == 0 {
- return ErrorResult("keys cannot be empty")
- }
-
- session, err := t.sessionManager.Get(sessionID)
- if err != nil {
- if errors.Is(err, ErrSessionNotFound) {
- return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
- }
- return ErrorResult(err.Error())
- }
-
- ptyKeyMode := session.GetPtyKeyMode()
-
- data, err := encodeKeySequence(keys, ptyKeyMode)
- if err != nil {
- return ErrorResult(fmt.Sprintf("invalid key: %v", err))
- }
-
- if session.IsDone() {
- return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
- }
-
- if err := session.Write(data); err != nil {
- if errors.Is(err, ErrSessionDone) {
- return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
- }
- return ErrorResult(fmt.Sprintf("failed to send keys: %v", err))
- }
-
- resp := ExecResponse{
- SessionID: sessionID,
- Status: "running",
- Output: fmt.Sprintf("Sent keys: %v", keys),
- }
- respData, _ := json.Marshal(resp)
- return &ToolResult{
- ForLLM: string(respData),
- IsError: false,
- }
-}
-
func (t *ExecTool) guardCommand(command, cwd string) string {
cmd := strings.TrimSpace(command)
lower := strings.ToLower(cmd)
diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go
index a8de2f4c9..f8f83ea74 100644
--- a/pkg/tools/shell_test.go
+++ b/pkg/tools/shell_test.go
@@ -2,16 +2,12 @@ package tools
import (
"context"
- "encoding/json"
"os"
"path/filepath"
- "runtime"
"strings"
"testing"
"time"
- "github.com/stretchr/testify/require"
-
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -24,7 +20,6 @@ func TestShellTool_Success(t *testing.T) {
ctx := context.Background()
args := map[string]any{
- "action": "run",
"command": "echo 'hello world'",
}
@@ -55,7 +50,6 @@ func TestShellTool_Failure(t *testing.T) {
ctx := context.Background()
args := map[string]any{
- "action": "run",
"command": "ls /nonexistent_directory_12345",
}
@@ -88,7 +82,6 @@ func TestShellTool_Timeout(t *testing.T) {
ctx := context.Background()
args := map[string]any{
- "action": "run",
"command": "sleep 10",
}
@@ -119,9 +112,8 @@ func TestShellTool_WorkingDir(t *testing.T) {
ctx := context.Background()
args := map[string]any{
- "action": "run",
- "command": "cat test.txt",
- "cwd": tmpDir,
+ "command": "cat test.txt",
+ "working_dir": tmpDir,
}
result := tool.Execute(ctx, args)
@@ -144,7 +136,6 @@ func TestShellTool_DangerousCommand(t *testing.T) {
ctx := context.Background()
args := map[string]any{
- "action": "run",
"command": "rm -rf /",
}
@@ -168,7 +159,6 @@ func TestShellTool_DangerousCommand_KillBlocked(t *testing.T) {
ctx := context.Background()
args := map[string]any{
- "action": "run",
"command": "kill 12345",
}
@@ -208,7 +198,6 @@ func TestShellTool_StderrCapture(t *testing.T) {
ctx := context.Background()
args := map[string]any{
- "action": "run",
"command": "sh -c 'echo stdout; echo stderr >&2'",
}
@@ -233,7 +222,6 @@ func TestShellTool_OutputTruncation(t *testing.T) {
ctx := context.Background()
// Generate long output (>10000 chars)
args := map[string]any{
- "action": "run",
"command": "python3 -c \"print('x' * 20000)\" || echo " + strings.Repeat("x", 20000),
}
@@ -263,9 +251,8 @@ func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) {
}
result := tool.Execute(context.Background(), map[string]any{
- "action": "run",
- "command": "pwd",
- "cwd": outsideDir,
+ "command": "pwd",
+ "working_dir": outsideDir,
})
if !result.IsError {
@@ -302,9 +289,8 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) {
}
result := tool.Execute(context.Background(), map[string]any{
- "action": "run",
- "command": "cat secret.txt",
- "cwd": link,
+ "command": "cat secret.txt",
+ "working_dir": link,
})
if !result.IsError {
@@ -326,7 +312,7 @@ func TestShellTool_RemoteChannelBlockedByDefault(t *testing.T) {
t.Fatalf("NewExecToolWithConfig() error: %v", err)
}
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
- result := tool.Execute(ctx, map[string]any{"action": "run", "command": "echo hi"})
+ result := tool.Execute(ctx, map[string]any{"command": "echo hi"})
if !result.IsError {
t.Fatal("expected remote-channel exec to be blocked")
@@ -347,7 +333,7 @@ func TestShellTool_InternalChannelAllowed(t *testing.T) {
t.Fatalf("NewExecToolWithConfig() error: %v", err)
}
ctx := WithToolContext(context.Background(), "cli", "direct")
- result := tool.Execute(ctx, map[string]any{"action": "run", "command": "echo hi"})
+ result := tool.Execute(ctx, map[string]any{"command": "echo hi"})
if result.IsError {
t.Fatalf("expected internal channel exec to succeed, got: %s", result.ForLLM)
@@ -387,7 +373,7 @@ func TestShellTool_AllowRemoteBypassesChannelCheck(t *testing.T) {
t.Fatalf("NewExecToolWithConfig() error: %v", err)
}
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
- result := tool.Execute(ctx, map[string]any{"action": "run", "command": "echo hi"})
+ result := tool.Execute(ctx, map[string]any{"command": "echo hi"})
if result.IsError {
t.Fatalf("expected allowRemote=true to permit remote channel, got: %s", result.ForLLM)
@@ -406,7 +392,6 @@ func TestShellTool_RestrictToWorkspace(t *testing.T) {
ctx := context.Background()
args := map[string]any{
- "action": "run",
"command": "cat ../../etc/passwd",
}
@@ -444,7 +429,7 @@ func TestShellTool_DevNullAllowed(t *testing.T) {
}
for _, cmd := range commands {
- result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd})
+ result := tool.Execute(context.Background(), map[string]any{"command": cmd})
if result.IsError && strings.Contains(result.ForLLM, "blocked") {
t.Errorf("command should not be blocked: %s\n error: %s", cmd, result.ForLLM)
}
@@ -473,7 +458,7 @@ func TestShellTool_BlockDevices(t *testing.T) {
}
for _, cmd := range blocked {
- result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd})
+ result := tool.Execute(context.Background(), map[string]any{"command": cmd})
if !result.IsError {
t.Errorf("expected block device write to be blocked: %s", cmd)
}
@@ -497,7 +482,7 @@ func TestShellTool_SafePathsInWorkspaceRestriction(t *testing.T) {
}
for _, cmd := range commands {
- result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd})
+ result := tool.Execute(context.Background(), map[string]any{"command": cmd})
if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") {
t.Errorf("safe path should not be blocked by workspace check: %s\n error: %s", cmd, result.ForLLM)
}
@@ -513,7 +498,6 @@ func TestShellTool_ExitCodeDetails(t *testing.T) {
ctx := context.Background()
args := map[string]any{
- "action": "run",
"command": "sh -c 'exit 42'",
}
@@ -550,7 +534,6 @@ func TestShellTool_TimeoutWithPartialOutput(t *testing.T) {
ctx := context.Background()
// Use a command that outputs immediately then sleeps
args := map[string]any{
- "action": "run",
"command": "echo 'partial output before timeout' && sleep 30",
}
@@ -625,9 +608,7 @@ func TestShellTool_URLsNotBlocked(t *testing.T) {
}
for _, cmd := range commands {
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
- result := tool.Execute(ctx, map[string]any{"action": "run", "command": cmd})
- cancel()
+ result := tool.Execute(context.Background(), map[string]any{"command": cmd})
if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") {
t.Errorf("command with URL should not be blocked by workspace check: %s\n error: %s", cmd, result.ForLLM)
}
@@ -652,7 +633,7 @@ func TestShellTool_FileURISandboxing(t *testing.T) {
}
for _, cmd := range blockedCommands {
- result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd})
+ result := tool.Execute(context.Background(), map[string]any{"command": cmd})
if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") {
t.Errorf("file:// URI outside workspace should be blocked: %s", cmd)
}
@@ -670,7 +651,7 @@ func TestShellTool_FileURISandboxing(t *testing.T) {
}
for _, cmd := range allowedCommands {
- result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd})
+ result := tool.Execute(context.Background(), map[string]any{"command": cmd})
if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") {
t.Errorf("file:// URI inside workspace should be allowed: %s\n error: %s", cmd, result.ForLLM)
}
@@ -696,920 +677,9 @@ func TestShellTool_URLBypassPrevented(t *testing.T) {
}
for _, cmd := range blockedCommands {
- result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd})
+ result := tool.Execute(context.Background(), map[string]any{"command": cmd})
if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") {
t.Errorf("bypass attempt should be blocked: %q\n got: %s", cmd, result.ForLLM)
}
}
}
-
-func TestShellTool_Background_ReturnsImmediately(t *testing.T) {
- tool, err := NewExecTool("", false)
- require.NoError(t, err)
-
- ctx := context.Background()
- args := map[string]any{
- "action": "run",
- "command": "sleep 5",
- "background": "true",
- }
-
- start := time.Now()
- result := tool.Execute(ctx, args)
- elapsed := time.Since(start)
-
- require.False(t, result.IsError, "background run should not error: %s", result.ForLLM)
- require.Less(t, elapsed, time.Second, "background run should return immediately")
- require.Contains(t, result.ForLLM, "sessionId")
-}
-
-func TestShellTool_List_Empty(t *testing.T) {
- tool, err := NewExecTool("", false)
- require.NoError(t, err)
-
- sm := NewSessionManager()
- tool.sessionManager = sm
-
- ctx := context.Background()
- args := map[string]any{"action": "list"}
-
- result := tool.Execute(ctx, args)
- require.False(t, result.IsError)
- require.Contains(t, result.ForUser, "0 active sessions")
-}
-
-func TestShellTool_RunBackground_List(t *testing.T) {
- tool, err := NewExecTool("", false)
- require.NoError(t, err)
-
- sm := NewSessionManager()
- tool.sessionManager = sm
-
- ctx := WithToolContext(context.Background(), "cli", "test")
-
- runResult := tool.Execute(ctx, map[string]any{
- "action": "run",
- "command": "sleep 10",
- "background": "true",
- })
- require.False(t, runResult.IsError, "run should succeed: %s", runResult.ForLLM)
-
- var resp ExecResponse
- err = json.Unmarshal([]byte(runResult.ForLLM), &resp)
- require.NoError(t, err)
- require.NotEmpty(t, resp.SessionID)
-
- time.Sleep(100 * time.Millisecond)
-
- listResult := tool.Execute(ctx, map[string]any{"action": "list"})
- require.False(t, listResult.IsError)
-
- var listResp ExecResponse
- err = json.Unmarshal([]byte(listResult.ForLLM), &listResp)
- require.NoError(t, err)
- require.Len(t, listResp.Sessions, 1)
- require.Equal(t, resp.SessionID, listResp.Sessions[0].ID)
-
- killResult := tool.Execute(ctx, map[string]any{
- "action": "kill",
- "sessionId": resp.SessionID,
- })
- require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM)
-}
-
-func TestShellTool_Read_Output(t *testing.T) {
- tool, err := NewExecTool("", false)
- require.NoError(t, err)
-
- sm := NewSessionManager()
- tool.sessionManager = sm
-
- ctx := WithToolContext(context.Background(), "cli", "test")
-
- runResult := tool.Execute(ctx, map[string]any{
- "action": "run",
- "command": "echo hello",
- "background": "true",
- })
- require.False(t, runResult.IsError)
-
- var resp ExecResponse
- err = json.Unmarshal([]byte(runResult.ForLLM), &resp)
- require.NoError(t, err)
-
- time.Sleep(200 * time.Millisecond)
-
- readResult := tool.Execute(ctx, map[string]any{
- "action": "read",
- "sessionId": resp.SessionID,
- })
-
- if !readResult.IsError {
- var readResp ExecResponse
- err = json.Unmarshal([]byte(readResult.ForLLM), &readResp)
- require.NoError(t, err)
- }
-}
-
-func TestShellTool_Kill(t *testing.T) {
- tool, err := NewExecTool("", false)
- require.NoError(t, err)
-
- sm := NewSessionManager()
- tool.sessionManager = sm
-
- ctx := WithToolContext(context.Background(), "cli", "test")
-
- runResult := tool.Execute(ctx, map[string]any{
- "action": "run",
- "command": "sleep 100",
- "background": "true",
- })
- require.False(t, runResult.IsError)
-
- var resp ExecResponse
- err = json.Unmarshal([]byte(runResult.ForLLM), &resp)
- require.NoError(t, err)
-
- killResult := tool.Execute(ctx, map[string]any{
- "action": "kill",
- "sessionId": resp.SessionID,
- })
- require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM)
-
- time.Sleep(100 * time.Millisecond)
-
- listResult := tool.Execute(ctx, map[string]any{"action": "list"})
- var listResp ExecResponse
- err = json.Unmarshal([]byte(listResult.ForLLM), &listResp)
- require.NoError(t, err)
- require.Len(t, listResp.Sessions, 0)
-}
-
-func TestShellTool_PTY_AllowedCommands(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("PTY not supported on Windows")
- }
-
- tool, err := NewExecTool("", false)
- require.NoError(t, err)
-
- sm := NewSessionManager()
- tool.sessionManager = sm
-
- ctx := WithToolContext(context.Background(), "cli", "test")
-
- // Test that PTY is allowed for non-interpreter commands
- result := tool.Execute(ctx, map[string]any{
- "action": "run",
- "command": "cat",
- "pty": "true",
- "background": "true",
- })
- require.False(t, result.IsError, "PTY with cat should succeed: %s", result.ForLLM)
- require.Contains(t, result.ForLLM, "sessionId")
-
- var resp ExecResponse
- err = json.Unmarshal([]byte(result.ForLLM), &resp)
- require.NoError(t, err)
- require.NotEmpty(t, resp.SessionID)
-
- // Clean up
- tool.Execute(ctx, map[string]any{
- "action": "kill",
- "sessionId": resp.SessionID,
- })
-}
-
-func TestShellTool_PTY_WriteRead(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("PTY not supported on Windows")
- }
-
- tool, err := NewExecTool("", false)
- require.NoError(t, err)
-
- sm := NewSessionManager()
- tool.sessionManager = sm
-
- ctx := WithToolContext(context.Background(), "cli", "test")
-
- // Start a PTY session with a command that waits for input
- // Using 'cat' which will wait for stdin
- result := tool.Execute(ctx, map[string]any{
- "action": "run",
- "command": "cat",
- "pty": "true",
- "background": "true",
- })
- require.False(t, result.IsError, "PTY run should succeed: %s", result.ForLLM)
-
- var resp ExecResponse
- err = json.Unmarshal([]byte(result.ForLLM), &resp)
- require.NoError(t, err)
-
- // Write some input to cat
- writeResult := tool.Execute(ctx, map[string]any{
- "action": "write",
- "sessionId": resp.SessionID,
- "data": "hello\n",
- })
- require.False(t, writeResult.IsError, "write should succeed: %s", writeResult.ForLLM)
-
- // Give cat time to process and output
- time.Sleep(200 * time.Millisecond)
-
- // Read the output
- readResult := tool.Execute(ctx, map[string]any{
- "action": "read",
- "sessionId": resp.SessionID,
- })
-
- require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM)
-
- var readResp ExecResponse
- err = json.Unmarshal([]byte(readResult.ForLLM), &readResp)
- require.NoError(t, err)
- // PTY output should contain "hello"
- require.Contains(t, readResp.Output, "hello")
-
- // Clean up
- tool.Execute(ctx, map[string]any{
- "action": "kill",
- "sessionId": resp.SessionID,
- })
-}
-
-func TestShellTool_PTY_Poll(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("PTY not supported on Windows")
- }
-
- tool, err := NewExecTool("", false)
- require.NoError(t, err)
-
- sm := NewSessionManager()
- tool.sessionManager = sm
-
- ctx := WithToolContext(context.Background(), "cli", "test")
-
- // Start a PTY session with a long-running command
- result := tool.Execute(ctx, map[string]any{
- "action": "run",
- "command": "sleep 2",
- "pty": "true",
- "background": "true",
- })
- require.False(t, result.IsError, "PTY run should succeed: %s", result.ForLLM)
-
- var resp ExecResponse
- err = json.Unmarshal([]byte(result.ForLLM), &resp)
- require.NoError(t, err)
-
- // Poll should show running
- pollResult := tool.Execute(ctx, map[string]any{
- "action": "poll",
- "sessionId": resp.SessionID,
- })
- require.False(t, pollResult.IsError, "poll should succeed: %s", pollResult.ForLLM)
-
- var pollResp ExecResponse
- err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp)
- require.NoError(t, err)
- require.Equal(t, "running", pollResp.Status)
-
- // Wait for sleep to complete
- time.Sleep(2500 * time.Millisecond)
-
- // Poll should show done
- pollResult = tool.Execute(ctx, map[string]any{
- "action": "poll",
- "sessionId": resp.SessionID,
- })
- require.False(t, pollResult.IsError)
-
- err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp)
- require.NoError(t, err)
- require.Equal(t, "done", pollResp.Status)
-}
-
-func TestShellTool_PTY_Kill(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("PTY not supported on Windows")
- }
-
- tool, err := NewExecTool("", false)
- require.NoError(t, err)
-
- sm := NewSessionManager()
- tool.sessionManager = sm
-
- ctx := WithToolContext(context.Background(), "cli", "test")
-
- // Start a PTY session with a long-running command
- result := tool.Execute(ctx, map[string]any{
- "action": "run",
- "command": "sleep 10",
- "pty": "true",
- "background": "true",
- })
- require.False(t, result.IsError, "PTY run should succeed: %s", result.ForLLM)
-
- var resp ExecResponse
- err = json.Unmarshal([]byte(result.ForLLM), &resp)
- require.NoError(t, err)
-
- // Kill the session
- killResult := tool.Execute(ctx, map[string]any{
- "action": "kill",
- "sessionId": resp.SessionID,
- })
- require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM)
-
- // Verify kill response shows done status
- var killResp ExecResponse
- err = json.Unmarshal([]byte(killResult.ForLLM), &killResp)
- require.NoError(t, err)
- require.Equal(t, "done", killResp.Status)
-
- // Poll should return error since session is removed after kill
- pollResult := tool.Execute(ctx, map[string]any{
- "action": "poll",
- "sessionId": resp.SessionID,
- })
- // Session is removed after kill, so poll returns error with "session not found"
- require.True(t, pollResult.IsError, "poll should error after kill (session removed)")
- require.Contains(t, pollResult.ForLLM, "session not found")
-}
-
-func TestShellTool_Write_Read_NonPTY(t *testing.T) {
- tool, err := NewExecTool("", false)
- require.NoError(t, err)
-
- sm := NewSessionManager()
- tool.sessionManager = sm
-
- ctx := WithToolContext(context.Background(), "cli", "test")
-
- // Start a background process that reads from stdin and outputs it
- // Using 'cat' which echoes stdin to stdout
- result := tool.Execute(ctx, map[string]any{
- "action": "run",
- "command": "cat",
- "pty": false,
- "background": "true",
- })
- require.False(t, result.IsError, "run should succeed: %s", result.ForLLM)
-
- var resp ExecResponse
- err = json.Unmarshal([]byte(result.ForLLM), &resp)
- require.NoError(t, err)
-
- // Write some input to cat
- writeResult := tool.Execute(ctx, map[string]any{
- "action": "write",
- "sessionId": resp.SessionID,
- "data": "hello world\n",
- })
- require.False(t, writeResult.IsError, "write should succeed: %s", writeResult.ForLLM)
-
- // Give cat time to process and output
- time.Sleep(200 * time.Millisecond)
-
- // Read the output
- readResult := tool.Execute(ctx, map[string]any{
- "action": "read",
- "sessionId": resp.SessionID,
- })
- require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM)
-
- var readResp ExecResponse
- err = json.Unmarshal([]byte(readResult.ForLLM), &readResp)
- require.NoError(t, err)
- require.Contains(t, readResp.Output, "hello world")
-
- // Clean up
- tool.Execute(ctx, map[string]any{
- "action": "kill",
- "sessionId": resp.SessionID,
- })
-}
-
-func TestShellTool_Read_NonPTY_Running(t *testing.T) {
- tool, err := NewExecTool("", false)
- require.NoError(t, err)
-
- sm := NewSessionManager()
- tool.sessionManager = sm
-
- ctx := WithToolContext(context.Background(), "cli", "test")
-
- // Start a long-running process that produces output over time
- // Using sh -c with sleep at the end so process doesn't exit immediately
- result := tool.Execute(ctx, map[string]any{
- "action": "run",
- "command": "sh -c 'echo line1; sleep 0.5; echo line2; sleep 0.5; echo line3; sleep 10'",
- "pty": false,
- "background": "true",
- })
- require.False(t, result.IsError, "run should succeed: %s", result.ForLLM)
-
- var resp ExecResponse
- err = json.Unmarshal([]byte(result.ForLLM), &resp)
- require.NoError(t, err)
-
- // Give time for first outputs to be produced
- time.Sleep(300 * time.Millisecond)
-
- // Read output while process is running
- readResult := tool.Execute(ctx, map[string]any{
- "action": "read",
- "sessionId": resp.SessionID,
- })
- require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM)
-
- var readResp ExecResponse
- err = json.Unmarshal([]byte(readResult.ForLLM), &readResp)
- require.NoError(t, err)
- // Should have at least line1
- require.Contains(t, readResp.Output, "line1")
-
- // Wait for line3 to be produced (line1=0s, line2=0.5s, line3=1s, then sleep 10)
- time.Sleep(1200 * time.Millisecond)
-
- // Read again - should have line3 as well
- readResult = tool.Execute(ctx, map[string]any{
- "action": "read",
- "sessionId": resp.SessionID,
- })
- require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM)
-
- err = json.Unmarshal([]byte(readResult.ForLLM), &readResp)
- require.NoError(t, err)
- require.Contains(t, readResp.Output, "line3")
-
- // Clean up
- tool.Execute(ctx, map[string]any{
- "action": "kill",
- "sessionId": resp.SessionID,
- })
-}
-
-func TestShellTool_ProcessGroupKill(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("Process group kill not supported on Windows")
- }
-
- // Note: Testing process group kill with PTY is tricky because the command
- // must be run through an interpreter (sh, bash) which is blocked for PTY.
- // Instead, we test with non-PTY mode which also uses Setsid for background processes.
-
- tool, err := NewExecTool("", false)
- require.NoError(t, err)
-
- sm := NewSessionManager()
- tool.sessionManager = sm
-
- ctx := WithToolContext(context.Background(), "cli", "test")
-
- // Start a shell that spawns child processes (non-PTY mode)
- // The sh -c command creates child sleep processes
- result := tool.Execute(ctx, map[string]any{
- "action": "run",
- "command": "sh -c 'sleep 30 & sleep 30 & wait'",
- "pty": false,
- "background": "true",
- })
- require.False(t, result.IsError, "run should succeed: %s", result.ForLLM)
-
- var resp ExecResponse
- err = json.Unmarshal([]byte(result.ForLLM), &resp)
- require.NoError(t, err)
-
- // Give time for child processes to spawn
- time.Sleep(500 * time.Millisecond)
-
- // Kill the session - should kill the entire process group
- killResult := tool.Execute(ctx, map[string]any{
- "action": "kill",
- "sessionId": resp.SessionID,
- })
- require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM)
-
- // Verify kill response shows done status
- var killResp ExecResponse
- err = json.Unmarshal([]byte(killResult.ForLLM), &killResp)
- require.NoError(t, err)
- require.Equal(t, "done", killResp.Status)
-
- // Poll should return error since session is removed after kill
- pollResult := tool.Execute(ctx, map[string]any{
- "action": "poll",
- "sessionId": resp.SessionID,
- })
- require.True(t, pollResult.IsError, "poll should error after kill (session removed)")
- require.Contains(t, pollResult.ForLLM, "session not found")
-}
-
-func TestShellTool_PTY_ProcessGroupKill(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("PTY process group kill not supported on Windows")
- }
-
- // This test binary creates 4 child sleep processes and waits for signals.
- // It's not an interpreter, so it's allowed with PTY mode.
- // The binary is created in /tmp/test_pgroup.c and compiled as part of test setup.
- testBinary := "/tmp/test_pgroup"
- if _, err := os.Stat(testBinary); os.IsNotExist(err) {
- t.Skip("Test binary /tmp/test_pgroup not found - run: gcc -o /tmp/test_pgroup /tmp/test_pgroup.c")
- }
-
- tool, err := NewExecTool("", false)
- require.NoError(t, err)
-
- sm := NewSessionManager()
- tool.sessionManager = sm
-
- ctx := WithToolContext(context.Background(), "cli", "test")
-
- // Start the test binary with PTY mode
- // It forks 4 child sleep processes and waits for signals
- result := tool.Execute(ctx, map[string]any{
- "action": "run",
- "command": testBinary,
- "pty": "true",
- "background": "true",
- })
- require.False(t, result.IsError, "run should succeed: %s", result.ForLLM)
-
- var resp ExecResponse
- err = json.Unmarshal([]byte(result.ForLLM), &resp)
- require.NoError(t, err)
-
- // Give time for child processes to spawn
- time.Sleep(500 * time.Millisecond)
-
- // Kill the session - should kill the entire process group
- killResult := tool.Execute(ctx, map[string]any{
- "action": "kill",
- "sessionId": resp.SessionID,
- })
- require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM)
-
- // Verify kill response shows done status
- var killResp ExecResponse
- err = json.Unmarshal([]byte(killResult.ForLLM), &killResp)
- require.NoError(t, err)
- require.Equal(t, "done", killResp.Status)
-
- // Poll should return error since session is removed after kill
- pollResult := tool.Execute(ctx, map[string]any{
- "action": "poll",
- "sessionId": resp.SessionID,
- })
- require.True(t, pollResult.IsError, "poll should error after kill (session removed)")
- require.Contains(t, pollResult.ForLLM, "session not found")
-}
-
-func TestShellTool_PTY_Background_Read(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("PTY not supported on Windows")
- }
-
- tool, err := NewExecTool("", false)
- require.NoError(t, err)
-
- sm := NewSessionManager()
- tool.sessionManager = sm
-
- ctx := WithToolContext(context.Background(), "cli", "test")
-
- // Start a fast command with PTY + background mode
- runResult := tool.Execute(ctx, map[string]any{
- "action": "run",
- "command": "echo hello",
- "pty": "true",
- "background": "true",
- })
- require.False(t, runResult.IsError, "run should succeed: %s", runResult.ForLLM)
-
- var runResp ExecResponse
- err = json.Unmarshal([]byte(runResult.ForLLM), &runResp)
- require.NoError(t, err)
- require.NotEmpty(t, runResp.SessionID)
- require.Equal(t, "running", runResp.Status)
-
- // Wait for command to complete
- time.Sleep(500 * time.Millisecond)
-
- // Read output - this is the key test: PTY + background mode should preserve output
- readResult := tool.Execute(ctx, map[string]any{
- "action": "read",
- "sessionId": runResp.SessionID,
- })
- require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM)
- require.Contains(t, readResult.ForLLM, "hello", "output should contain 'hello'")
-}
-
-func TestShellTool_PTY_Background_ReadNoBlock(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("PTY not supported on Windows")
- }
-
- tool, err := NewExecTool("", false)
- require.NoError(t, err)
-
- sm := NewSessionManager()
- tool.sessionManager = sm
-
- ctx := WithToolContext(context.Background(), "cli", "test")
-
- // Start a long-running command with PTY + background mode
- // This command produces no output, just sleeps
- runResult := tool.Execute(ctx, map[string]any{
- "action": "run",
- "command": "sleep 10",
- "pty": "true",
- "background": "true",
- })
- require.False(t, runResult.IsError, "run should succeed: %s", runResult.ForLLM)
-
- var runResp ExecResponse
- err = json.Unmarshal([]byte(runResult.ForLLM), &runResp)
- require.NoError(t, err)
- require.NotEmpty(t, runResp.SessionID)
-
- // Read immediately - should NOT block even though process is running and has no output
- // This tests that Read() returns quickly (within 1 second) instead of blocking for 10 seconds
- start := time.Now()
- readResult := tool.Execute(ctx, map[string]any{
- "action": "read",
- "sessionId": runResp.SessionID,
- })
- elapsed := time.Since(start)
-
- require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM)
- require.Less(t, elapsed.Seconds(), 1.0, "read should not block, should return within 1 second")
-
- // Kill the session to clean up
- killResult := tool.Execute(ctx, map[string]any{
- "action": "kill",
- "sessionId": runResp.SessionID,
- })
- require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM)
-}
-
-func TestShellTool_Poll_Status(t *testing.T) {
- tool, err := NewExecTool("", false)
- require.NoError(t, err)
-
- sm := NewSessionManager()
- tool.sessionManager = sm
-
- ctx := WithToolContext(context.Background(), "cli", "test")
-
- runResult := tool.Execute(ctx, map[string]any{
- "action": "run",
- "command": "sleep 1",
- "background": "true",
- })
- require.False(t, runResult.IsError)
-
- var resp ExecResponse
- err = json.Unmarshal([]byte(runResult.ForLLM), &resp)
- require.NoError(t, err)
-
- pollResult := tool.Execute(ctx, map[string]any{
- "action": "poll",
- "sessionId": resp.SessionID,
- })
- require.False(t, pollResult.IsError)
-
- var pollResp ExecResponse
- err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp)
- require.NoError(t, err)
- require.Equal(t, "running", pollResp.Status)
-
- time.Sleep(1200 * time.Millisecond)
-
- pollResult = tool.Execute(ctx, map[string]any{
- "action": "poll",
- "sessionId": resp.SessionID,
- })
- require.False(t, pollResult.IsError)
-
- err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp)
- require.NoError(t, err)
- require.Equal(t, "done", pollResp.Status)
-}
-
-func TestShellTool_Action_Run_Sync(t *testing.T) {
- tool, err := NewExecTool("", false)
- require.NoError(t, err)
-
- ctx := context.Background()
-
- result := tool.Execute(ctx, map[string]any{
- "action": "run",
- "command": "echo hello",
- })
-
- require.False(t, result.IsError)
- require.Contains(t, result.ForLLM, "hello")
-}
-
-// TestShellTool_Background_ReadAfterExit verifies that we can read
-// buffered output even after the background process has exited.
-func TestShellTool_Background_ReadAfterExit(t *testing.T) {
- tool, err := NewExecTool("", false)
- require.NoError(t, err)
-
- ctx := context.Background()
-
- // Start a background command that produces output and exits quickly
- runResult := tool.Execute(ctx, map[string]any{
- "action": "run",
- "command": "echo hello && sleep 1 && echo world",
- "background": "true",
- })
- require.False(t, runResult.IsError, "run should succeed: %s", runResult.ForUser)
-
- // Parse session ID from response
- var resp ExecResponse
- err = json.Unmarshal([]byte(runResult.ForLLM), &resp)
- require.NoError(t, err)
- require.NotEmpty(t, resp.SessionID)
- sessionID := resp.SessionID
-
- // Wait for process to exit (sleep 1 + some buffer)
- time.Sleep(1500 * time.Millisecond)
-
- // Poll to verify process is done
- pollResult := tool.Execute(ctx, map[string]any{
- "action": "poll",
- "sessionId": sessionID,
- })
- require.False(t, pollResult.IsError, "poll should succeed: %s", pollResult.ForLLM)
- var pollResp ExecResponse
- err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp)
- require.NoError(t, err)
- require.Equal(t, "done", pollResp.Status, "process should be done")
-
- // Try to read output AFTER process has exited
- readResult := tool.Execute(ctx, map[string]any{
- "action": "read",
- "sessionId": sessionID,
- })
- require.False(t, readResult.IsError, "read should succeed after exit: %s", readResult.ForLLM)
-
- var readResp ExecResponse
- err = json.Unmarshal([]byte(readResult.ForLLM), &readResp)
- require.NoError(t, err)
-
- // Output should contain both "hello" and "world"
- require.Contains(t, readResp.Output, "hello", "should contain hello")
- require.Contains(t, readResp.Output, "world", "should contain world after sleep")
-}
-
-func TestSendKeys_CtrlC(t *testing.T) {
- // Note: Ctrl-C as a signal requires sending SIGINT to the process group,
- // which requires elevated privileges. Writing "\x03" to PTY passes the byte
- // to the process but doesn't generate SIGINT for processes that don't read stdin.
- // For interrupting processes, use the kill action instead.
- t.Skip("Ctrl-C as signal not supported - use kill action for interruption")
-}
-
-func TestEncodeKeyToken(t *testing.T) {
- tests := []struct {
- token string
- expected string
- hasError bool
- }{
- // Named keys
- {"enter", "\r", false},
- {"return", "\r", false},
- {"tab", "\t", false},
- {"escape", "\x1b", false},
- {"esc", "\x1b", false},
- {"backspace", "\x7f", false},
- {"up", "\x1b[A", false},
- {"down", "\x1b[B", false},
- {"left", "\x1b[D", false},
- {"right", "\x1b[C", false},
- {"home", "\x1b[1~", false},
- {"end", "\x1b[4~", false},
- {"pageup", "\x1b[5~", false},
- {"pagedown", "\x1b[6~", false},
- {"delete", "\x1b[3~", false},
- {"f1", "\x1bOP", false},
- {"f12", "\x1b[24~", false},
-
- // Ctrl keys
- {"ctrl-c", "\x03", false},
- {"ctrl-d", "\x04", false},
- {"ctrl-a", "\x01", false},
- {"ctrl-z", "\x1a", false},
- {"c-c", "\x03", false},
- {"c-d", "\x04", false},
-
- // Alt keys
- {"alt-x", "\x1bx", false},
- {"m-x", "\x1bx", false},
-
- // Case insensitive tests
- {"ENTER", "\r", false},
- {"TAB", "\t", false},
- {"CTRL-C", "\x03", false},
- {"Ctrl-D", "\x04", false},
- {"ALT-X", "\x1bx", false},
- {"M-X", "\x1bx", false},
- {"UP", "\x1b[A", false},
- {"DOWN", "\x1b[B", false},
-
- // Unknown keys should return error (use write action for text input)
- {"unknown-key", "", true},
- }
-
- for _, tt := range tests {
- t.Run(tt.token, func(t *testing.T) {
- result, err := encodeKeyToken(tt.token, PtyKeyModeCSI)
- if tt.hasError {
- require.Error(t, err, "expected error for %s", tt.token)
- } else {
- require.NoError(t, err, "unexpected error for %s", tt.token)
- require.Equal(t, tt.expected, result, "wrong encoding for %s", tt.token)
- }
- })
- }
-}
-
-// TestDetectPtyKeyMode tests smkx/rmkx detection in PTY output
-func TestDetectPtyKeyMode(t *testing.T) {
- tests := []struct {
- name string
- raw string
- expected PtyKeyMode
- }{
- {"no toggle", "hello world", PtyKeyModeNotFound},
- {"smkx only", "\x1b[?1h\x1b=", PtyKeyModeSS3},
- {"rmkx only", "\x1b[?1l\x1b>", PtyKeyModeCSI},
- {"both smkx first", "\x1b[?1h\x1b=...\x1b[?1l\x1b>", PtyKeyModeCSI},
- {"both rmkx first", "\x1b[?1l\x1b>...\x1b[?1h\x1b=", PtyKeyModeSS3},
- {"multiple toggles smkx last", "\x1b[?1h\x1b=...\x1b[?1l\x1b>...\x1b[?1h\x1b=", PtyKeyModeSS3},
- {"multiple toggles rmkx last", "\x1b[?1l\x1b>...\x1b[?1h\x1b=...\x1b[?1l\x1b>", PtyKeyModeCSI},
- {"partial smkx", "\x1b[?1h", PtyKeyModeSS3},
- {"partial rmkx", "\x1b[?1l", PtyKeyModeCSI},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := detectPtyKeyMode(tt.raw)
- require.Equal(t, tt.expected, result, "wrong mode for %s", tt.name)
- })
- }
-}
-
-func TestEncodeKeyTokenWithPtyKeyMode(t *testing.T) {
- tests := []struct {
- name string
- token string
- mode PtyKeyMode
- expected string
- hasError bool
- }{
- // CSI mode
- {"up csi", "up", PtyKeyModeCSI, "\x1b[A", false},
- {"down csi", "down", PtyKeyModeCSI, "\x1b[B", false},
- {"left csi", "left", PtyKeyModeCSI, "\x1b[D", false},
- {"right csi", "right", PtyKeyModeCSI, "\x1b[C", false},
-
- // SS3 mode
- {"up ss3", "up", PtyKeyModeSS3, "\x1bOA", false},
- {"down ss3", "down", PtyKeyModeSS3, "\x1bOB", false},
- {"left ss3", "left", PtyKeyModeSS3, "\x1bOD", false},
- {"right ss3", "right", PtyKeyModeSS3, "\x1bOC", false},
- {"home ss3", "home", PtyKeyModeSS3, "\x1bOH", false},
- {"end ss3", "end", PtyKeyModeSS3, "\x1bOF", false},
-
- // Other keys unaffected by mode
- {"enter ss3", "enter", PtyKeyModeSS3, "\r", false},
- {"tab ss3", "tab", PtyKeyModeSS3, "\t", false},
- {"ctrl-c ss3", "ctrl-c", PtyKeyModeSS3, "\x03", false},
-
- // NotFound behaves like CSI
- {"up notfound", "up", PtyKeyModeNotFound, "\x1b[A", false},
- {"down notfound", "down", PtyKeyModeNotFound, "\x1b[B", false},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result, err := encodeKeyToken(tt.token, tt.mode)
- if tt.hasError {
- require.Error(t, err, "expected error for %s", tt.name)
- } else {
- require.NoError(t, err, "unexpected error for %s", tt.name)
- require.Equal(t, tt.expected, result, "wrong encoding for %s", tt.name)
- }
- })
- }
-}
diff --git a/pkg/tools/shell_timeout_unix_test.go b/pkg/tools/shell_timeout_unix_test.go
index dfd28454c..357e1276e 100644
--- a/pkg/tools/shell_timeout_unix_test.go
+++ b/pkg/tools/shell_timeout_unix_test.go
@@ -30,7 +30,6 @@ func TestShellTool_TimeoutKillsChildProcess(t *testing.T) {
tool.SetTimeout(500 * time.Millisecond)
args := map[string]any{
- "action": "run",
// Spawn a child process that would outlive the shell unless process-group kill is used.
"command": "sleep 60 & echo $! > child.pid; wait",
}
diff --git a/pkg/tools/types.go b/pkg/tools/types.go
index 4d1a18d5a..a6015cde3 100644
--- a/pkg/tools/types.go
+++ b/pkg/tools/types.go
@@ -56,24 +56,3 @@ type ToolFunctionDefinition struct {
Description string `json:"description"`
Parameters map[string]any `json:"parameters"`
}
-
-type ExecRequest struct {
- Action string `json:"action"`
- Command string `json:"command,omitempty"`
- PTY bool `json:"pty,omitempty"`
- Background bool `json:"background,omitempty"`
- Timeout int `json:"timeout,omitempty"`
- Env map[string]string `json:"env,omitempty"`
- Cwd string `json:"cwd,omitempty"`
- SessionID string `json:"sessionId,omitempty"`
- Data string `json:"data,omitempty"`
-}
-
-type ExecResponse struct {
- SessionID string `json:"sessionId,omitempty"`
- Status string `json:"status,omitempty"`
- ExitCode int `json:"exitCode,omitempty"`
- Output string `json:"output,omitempty"`
- Error string `json:"error,omitempty"`
- Sessions []SessionInfo `json:"sessions,omitempty"`
-}