diff --git a/README.md b/README.md index b040d0605..a54c1bea1 100644 --- a/README.md +++ b/README.md @@ -293,7 +293,7 @@ That's it! You have a working AI assistant in 2 minutes. ## 💬 Chat Apps -Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or WeCom +Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, WeCom, or XiaoYi | Channel | Setup | | ------------ | ---------------------------------- | @@ -304,6 +304,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We | **DingTalk** | Medium (app credentials) | | **LINE** | Medium (credentials + webhook URL) | | **WeCom** | Medium (CorpID + webhook setup) | +| **XiaoYi** | Medium (AK/SK + AgentID) |
Telegram (Recommended) diff --git a/README.zh.md b/README.zh.md index 7c9351cb4..40fb848cc 100644 --- a/README.zh.md +++ b/README.zh.md @@ -304,6 +304,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方 | **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) | | **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) | | **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) | +| **小艺 (XiaoYi)** | ⭐⭐ 中等 | 华为小艺 A2A 协议,支持语音助手生态 | [查看文档](docs/channels/xiaoyi/README.zh.md) | ## ClawdChat 加入 Agent 社交网络 diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index baa489b92..7293ccc37 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -25,6 +25,7 @@ import ( _ "github.com/sipeed/picoclaw/pkg/channels/wecom" _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp" _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native" + _ "github.com/sipeed/picoclaw/pkg/channels/xiaoyi" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/devices" diff --git a/docs/channels/xiaoyi/README.md b/docs/channels/xiaoyi/README.md new file mode 100644 index 000000000..55a2a0312 --- /dev/null +++ b/docs/channels/xiaoyi/README.md @@ -0,0 +1,49 @@ +# XiaoYi (小艺) + +XiaoYi Channel enables AI agent communication via Huawei's A2A (Agent-to-Agent) protocol, supporting text messages, file attachments, streaming responses, and status updates. + +## Configuration + +```json +{ + "channels": { + "xiaoyi": { + "enabled": true, + "ak": "your-access-key", + "sk": "your-secret-key", + "agent_id": "your-agent-id", + "ws_url1": "", + "ws_url2": "", + "allow_from": [] + } + } +} +``` + +| Field | Type | Required | Description | +| ---------- | ------ | -------- | ---------------------------------------------- | +| enabled | bool | Yes | Enable XiaoYi channel | +| ak | string | Yes | Access Key | +| sk | string | Yes | Secret Key | +| agent_id | string | Yes | Agent identifier | +| ws_url1 | string | No | Server 1 URL, defaults to official server | +| ws_url2 | string | No | Server 2 URL, defaults to backup server | +| allow_from | array | No | User ID whitelist, empty allows all users | + +## Setup + +For detailed setup instructions, please refer to the official Huawei documentation: [OpenClaw Integration Guide](https://developer.huawei.com/consumer/cn/doc/service/openclaw-0000002518410344) + +1. Register and create an OpenClaw type agent on Huawei Developer Platform +2. Obtain AK (Access Key) and SK (Secret Key) +3. Get Agent ID +4. Add configuration to config file +5. Start PicoClaw, XiaoYi Channel will automatically connect to XiaoYi servers + +## Features + +- **WebSocket long connection**: Dual server hot-standby support +- **Auto-reconnect**: Exponential backoff strategy, max 50 retries +- **Heartbeat**: Protocol-level + application-level dual heartbeat +- **Streaming response**: Support progressive result return +- **Status update**: Send "processing" status immediately upon receiving message diff --git a/docs/channels/xiaoyi/README.zh.md b/docs/channels/xiaoyi/README.zh.md new file mode 100644 index 000000000..8fdf653f7 --- /dev/null +++ b/docs/channels/xiaoyi/README.zh.md @@ -0,0 +1,49 @@ +# 小艺 (XiaoYi) + +小艺 Channel 通过华为小艺 A2A (Agent-to-Agent) 协议实现智能体通信,支持文本消息、文件附件、流式响应和状态更新。 + +## 配置 + +```json +{ + "channels": { + "xiaoyi": { + "enabled": true, + "ak": "your-access-key", + "sk": "your-secret-key", + "agent_id": "your-agent-id", + "ws_url1": "", + "ws_url2": "", + "allow_from": [] + } + } +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| ---------- | ------ | ---- | -------------------------------------------- | +| enabled | bool | 是 | 是否启用小艺频道 | +| ak | string | 是 | Access Key | +| sk | string | 是 | Secret Key | +| agent_id | string | 是 | Agent 标识 | +| ws_url1 | string | 否 | 服务器1 URL,默认为小艺官方服务器 | +| ws_url2 | string | 否 | 服务器2 URL,默认为小艺备用服务器 | +| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | + +## 设置流程 + +详细教程请参考华为官方文档:[OpenClaw 接入指南](https://developer.huawei.com/consumer/cn/doc/service/openclaw-0000002518410344) + +1. 在华为开发者平台注册并创建 OpenClaw 类型的智能体 +2. 获取 AK (Access Key) 和 SK (Secret Key) +3. 获取 Agent ID +4. 将配置填入配置文件中 +5. 启动 PicoClaw,小艺 Channel 将自动连接到小艺服务器 + +## 特性 + +- **WebSocket 长连接**:支持双服务器热备 +- **自动重连**:指数退避策略,最大重试 50 次 +- **心跳机制**:协议层 + 应用层双重心跳 +- **流式响应**:支持逐步返回结果 +- **状态更新**:收到消息后立即发送"处理中"状态 diff --git a/go.mod b/go.mod index d7f9b1901..87b615041 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/tencent-connect/botgo v0.2.1 + github.com/ystyle/xiaoyi-agent-sdk v0.0.0-20260228122638-808341089b7f go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 golang.org/x/oauth2 v0.35.0 golang.org/x/time v0.14.0 diff --git a/go.sum b/go.sum index 941ab67ce..c5ada5eba 100644 --- a/go.sum +++ b/go.sum @@ -202,6 +202,8 @@ github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTd github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/ystyle/xiaoyi-agent-sdk v0.0.0-20260228122638-808341089b7f h1:H2SONTyMFS8Iva/9FJmU+4unMPruf7Estc5HmTf7kNQ= +github.com/ystyle/xiaoyi-agent-sdk v0.0.0-20260228122638-808341089b7f/go.mod h1:IXaPNIZ8Ta3iIhw1DmlDS3T0UumdrNtv0rIIBWvE/MU= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 31af9672c..20204f392 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -263,6 +263,10 @@ func (m *Manager) initChannels() error { m.initChannel("pico", "Pico") } + if m.config.Channels.XiaoYi.Enabled && m.config.Channels.XiaoYi.AK != "" { + m.initChannel("xiaoyi", "XiaoYi") + } + logger.InfoCF("channels", "Channel initialization completed", map[string]any{ "enabled_channels": len(m.channels), }) diff --git a/pkg/channels/xiaoyi/init.go b/pkg/channels/xiaoyi/init.go new file mode 100644 index 000000000..a48210967 --- /dev/null +++ b/pkg/channels/xiaoyi/init.go @@ -0,0 +1,13 @@ +package xiaoyi + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("xiaoyi", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewXiaoYiChannel(cfg, b) + }) +} diff --git a/pkg/channels/xiaoyi/xiaoyi.go b/pkg/channels/xiaoyi/xiaoyi.go new file mode 100644 index 000000000..f4d45257c --- /dev/null +++ b/pkg/channels/xiaoyi/xiaoyi.go @@ -0,0 +1,195 @@ +package xiaoyi + +import ( + "context" + "errors" + "fmt" + "strings" + + xiaoyi "github.com/ystyle/xiaoyi-agent-sdk/pkg/client" + "github.com/ystyle/xiaoyi-agent-sdk/pkg/types" + + "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" +) + +type XiaoYiChannel struct { + *channels.BaseChannel + config *config.Config + client xiaoyi.Client + ctx context.Context + cancel context.CancelFunc +} + +func NewXiaoYiChannel(cfg *config.Config, messageBus *bus.MessageBus) (*XiaoYiChannel, error) { + xiaoyiCfg := cfg.Channels.XiaoYi + + base := channels.NewBaseChannel( + "xiaoyi", + xiaoyiCfg, + messageBus, + xiaoyiCfg.AllowFrom, + ) + + return &XiaoYiChannel{ + BaseChannel: base, + config: cfg, + }, nil +} + +func (c *XiaoYiChannel) Start(ctx context.Context) error { + xiaoyiCfg := c.config.Channels.XiaoYi + + if xiaoyiCfg.AK == "" || xiaoyiCfg.SK == "" || xiaoyiCfg.AgentID == "" { + return fmt.Errorf("xiaoyi ak, sk and agent_id are required") + } + + logger.InfoC("xiaoyi", "Starting XiaoYi channel") + + cfg := &types.Config{ + AK: xiaoyiCfg.AK, + SK: xiaoyiCfg.SK, + AgentID: xiaoyiCfg.AgentID, + WSUrl1: xiaoyiCfg.WSUrl1, + WSUrl2: xiaoyiCfg.WSUrl2, + SingleServer: true, + } + + c.client = xiaoyi.New(cfg) + + c.client.OnMessage(func(ctx context.Context, msg types.Message) error { + return c.handleMessage(ctx, msg) + }) + + c.client.OnClear(func(sessionID string) { + logger.InfoCF("xiaoyi", "Session cleared", map[string]any{ + "session": sessionID, + }) + }) + + c.client.OnCancel(func(sessionID, taskID string) { + logger.InfoCF("xiaoyi", "Task canceled", map[string]any{ + "session": sessionID, + "task": taskID, + }) + }) + + c.client.OnError(func(serverID string, err error) { + logger.ErrorCF("xiaoyi", "Server error", map[string]any{ + "server": serverID, + "error": err.Error(), + }) + }) + + c.ctx, c.cancel = context.WithCancel(ctx) + + if err := c.client.Connect(c.ctx); err != nil { + return fmt.Errorf("failed to connect xiaoyi: %w", err) + } + + c.SetRunning(true) + logger.InfoC("xiaoyi", "XiaoYi channel started successfully") + + return nil +} + +func (c *XiaoYiChannel) Stop(ctx context.Context) error { + logger.InfoC("xiaoyi", "Stopping XiaoYi channel") + c.SetRunning(false) + + if c.cancel != nil { + c.cancel() + } + + if c.client != nil { + c.client.Close() + } + + logger.InfoC("xiaoyi", "XiaoYi channel stopped") + return nil +} + +func (c *XiaoYiChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + parts := strings.SplitN(msg.ChatID, "|", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid chat_id format: %w", channels.ErrSendFailed) + } + + sessionID := parts[0] + taskID := parts[1] + + logger.DebugCF("xiaoyi", "Sending message", map[string]any{ + "session": sessionID, + "task": taskID, + "length": len(msg.Content), + }) + + if err := c.client.ReplyStream(ctx, taskID, sessionID, msg.Content, false, false); err != nil { + if errors.Is(err, types.ErrSessionNotFound) { + return fmt.Errorf("xiaoyi session not found: %w", channels.ErrSendFailed) + } + return fmt.Errorf("xiaoyi reply: %w", channels.ErrTemporary) + } + + if err := c.client.SendStatus(ctx, taskID, sessionID, "Completed", "completed"); err != nil { + logger.WarnCF("xiaoyi", "Failed to send completed status", map[string]any{"error": err.Error()}) + } + + return nil +} + +func (c *XiaoYiChannel) handleMessage(ctx context.Context, msg types.Message) error { + sessionID := msg.SessionID() + taskID := msg.TaskID() + text := strings.TrimSpace(msg.Text()) + + logger.InfoCF("xiaoyi", "Received message", map[string]any{ + "session": sessionID, + "task": taskID, + "text": text, + }) + + sender := bus.SenderInfo{ + Platform: "xiaoyi", + PlatformID: sessionID, + CanonicalID: identity.BuildCanonicalID("xiaoyi", sessionID), + } + + if !c.IsAllowedSender(sender) { + logger.DebugCF("xiaoyi", "Message rejected by allowlist", map[string]any{ + "session": sessionID, + }) + return nil + } + + chatID := fmt.Sprintf("%s|%s", sessionID, taskID) + peer := bus.Peer{Kind: "direct", ID: sessionID} + + if err := c.client.SendStatus(ctx, taskID, sessionID, "Processing...", "working"); err != nil { + logger.WarnCF("xiaoyi", "Failed to send status", map[string]any{"error": err.Error()}) + } + + metadata := map[string]string{ + "task_id": taskID, + } + + c.HandleMessage(ctx, + peer, + "", + sessionID, + chatID, + text, + nil, + metadata, + sender, + ) + + return nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 2e0215278..59bed461c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -204,6 +204,7 @@ type ChannelsConfig struct { WeCom WeComConfig `json:"wecom"` WeComApp WeComAppConfig `json:"wecom_app"` Pico PicoConfig `json:"pico"` + XiaoYi XiaoYiConfig `json:"xiaoyi"` } // GroupTriggerConfig controls when the bot responds in group chats. @@ -372,6 +373,16 @@ type PicoConfig struct { Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } +type XiaoYiConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_XIAOYI_ENABLED"` + AK string `json:"ak" env:"PICOCLAW_CHANNELS_XIAOYI_AK"` + SK string `json:"sk" env:"PICOCLAW_CHANNELS_XIAOYI_SK"` + AgentID string `json:"agent_id" env:"PICOCLAW_CHANNELS_XIAOYI_AGENT_ID"` + WSUrl1 string `json:"ws_url1" env:"PICOCLAW_CHANNELS_XIAOYI_WS_URL1"` + WSUrl2 string `json:"ws_url2" env:"PICOCLAW_CHANNELS_XIAOYI_WS_URL2"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_XIAOYI_ALLOW_FROM"` +} + type HeartbeatConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5