feat(channels): add xiaoyi channel support

- Add XiaoYiChannel in pkg/channels/xiaoyi/ subpackage
- Implement A2A protocol using xiaoyi-agent-sdk
- Use ReplyStream + SendStatus for long-task pattern
- Add XiaoYiConfig to config.go
- Register xiaoyi in manager.go initChannels
- Add blank import in gateway helpers
- Add documentation in docs/channels/xiaoyi/
- Update README.md and README.zh.md channel lists
This commit is contained in:
YSTYLE 2026-02-28 00:11:16 +08:00
parent c119e0d5e8
commit 693db82347
11 changed files with 324 additions and 1 deletions

View file

@ -293,7 +293,7 @@ That's it! You have a working AI assistant in 2 minutes.
## 💬 Chat Apps ## 💬 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 | | Channel | Setup |
| ------------ | ---------------------------------- | | ------------ | ---------------------------------- |
@ -304,6 +304,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We
| **DingTalk** | Medium (app credentials) | | **DingTalk** | Medium (app credentials) |
| **LINE** | Medium (credentials + webhook URL) | | **LINE** | Medium (credentials + webhook URL) |
| **WeCom** | Medium (CorpID + webhook setup) | | **WeCom** | Medium (CorpID + webhook setup) |
| **XiaoYi** | Medium (AK/SK + AgentID) |
<details> <details>
<summary><b>Telegram</b> (Recommended)</summary> <summary><b>Telegram</b> (Recommended)</summary>

View file

@ -304,6 +304,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) | | **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) |
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) | | **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) | | **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
| **小艺 (XiaoYi)** | ⭐⭐ 中等 | 华为小艺 A2A 协议,支持语音助手生态 | [查看文档](docs/channels/xiaoyi/README.zh.md) |
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络 ## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络

View file

@ -25,6 +25,7 @@ import (
_ "github.com/sipeed/picoclaw/pkg/channels/wecom" _ "github.com/sipeed/picoclaw/pkg/channels/wecom"
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp" _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native" _ "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/config"
"github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/devices" "github.com/sipeed/picoclaw/pkg/devices"

View file

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

View file

@ -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 次
- **心跳机制**:协议层 + 应用层双重心跳
- **流式响应**:支持逐步返回结果
- **状态更新**:收到消息后立即发送"处理中"状态

1
go.mod
View file

@ -19,6 +19,7 @@ require (
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/tencent-connect/botgo v0.2.1 github.com/tencent-connect/botgo v0.2.1
github.com/ystyle/xiaoyi-agent-sdk v0.0.0-20260227130030-0515fa30d618
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
golang.org/x/oauth2 v0.35.0 golang.org/x/oauth2 v0.35.0
golang.org/x/time v0.14.0 golang.org/x/time v0.14.0

2
go.sum
View file

@ -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/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 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/ystyle/xiaoyi-agent-sdk v0.0.0-20260227130030-0515fa30d618 h1:2lZ4jVfUzdWU2ubDjdrO5eqqUeDnPZBeUrgkIFjiGKg=
github.com/ystyle/xiaoyi-agent-sdk v0.0.0-20260227130030-0515fa30d618/go.mod h1:IXaPNIZ8Ta3iIhw1DmlDS3T0UumdrNtv0rIIBWvE/MU=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 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.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=

View file

@ -263,6 +263,10 @@ func (m *Manager) initChannels() error {
m.initChannel("pico", "Pico") 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{ logger.InfoCF("channels", "Channel initialization completed", map[string]any{
"enabled_channels": len(m.channels), "enabled_channels": len(m.channels),
}) })

View file

@ -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)
})
}

View file

@ -0,0 +1,191 @@
package xiaoyi
import (
"context"
"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 {
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(c.ctx,
peer,
"",
sessionID,
chatID,
text,
nil,
metadata,
sender,
)
return nil
}

View file

@ -204,6 +204,7 @@ type ChannelsConfig struct {
WeCom WeComConfig `json:"wecom"` WeCom WeComConfig `json:"wecom"`
WeComApp WeComAppConfig `json:"wecom_app"` WeComApp WeComAppConfig `json:"wecom_app"`
Pico PicoConfig `json:"pico"` Pico PicoConfig `json:"pico"`
XiaoYi XiaoYiConfig `json:"xiaoyi"`
} }
// GroupTriggerConfig controls when the bot responds in group chats. // GroupTriggerConfig controls when the bot responds in group chats.
@ -372,6 +373,16 @@ type PicoConfig struct {
Placeholder PlaceholderConfig `json:"placeholder,omitempty"` 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 { type HeartbeatConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5