Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| ------------ | ---------------------------------- |
Expand All @@ -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) |

<details>
<summary><b>Telegram</b> (Recommended)</summary>
Expand Down
1 change: 1 addition & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络

Expand Down
1 change: 1 addition & 0 deletions cmd/picoclaw/internal/gateway/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
49 changes: 49 additions & 0 deletions docs/channels/xiaoyi/README.md
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions docs/channels/xiaoyi/README.zh.md
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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-20260227130030-0515fa30d618
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
golang.org/x/oauth2 v0.35.0
golang.org/x/time v0.14.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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-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.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
Expand Down
4 changes: 4 additions & 0 deletions pkg/channels/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
Expand Down
13 changes: 13 additions & 0 deletions pkg/channels/xiaoyi/init.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
191 changes: 191 additions & 0 deletions pkg/channels/xiaoyi/xiaoyi.go
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will the completed status published here conflict with long message fragmentation?

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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential impact on sessionID parsing needs to be analyzed, introducing new considerations: whether it will affect the parsing of sessionID

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
}
11 changes: 11 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down