From 6507540061501a76b16dac4c38116d9bcfba1cf0 Mon Sep 17 00:00:00 2001 From: YSTYLE Date: Fri, 27 Feb 2026 10:46:36 +0800 Subject: [PATCH] feat(channels): add xiaoyi channel support - Add XiaoYiChannel implementation using xiaoyi-agent-sdk - Support A2A protocol for Huawei XiaoYi integration - Include message routing via sessionID:taskID format - Send status update immediately when receiving message --- cmd/picoclaw/internal/gateway/helpers.go | 10 ++ go.mod | 1 + go.sum | 2 + pkg/channels/manager.go | 13 ++ pkg/channels/xiaoyi.go | 153 +++++++++++++++++++++++ pkg/config/config.go | 11 ++ 6 files changed, 190 insertions(+) create mode 100644 pkg/channels/xiaoyi.go diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index a06625dc9..fcc356714 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -149,6 +149,16 @@ func gatewayCmd(debug bool) error { } } + // Inject process function into XiaoYi channel + if xiaoyiChannel, ok := channelManager.GetChannel("xiaoyi"); ok { + if xc, ok := xiaoyiChannel.(*channels.XiaoYiChannel); ok { + xc.SetProcessFunc(func(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) { + return agentLoop.ProcessDirectWithChannel(ctx, content, sessionKey, channel, chatID) + }) + logger.InfoC("xiaoyi", "Agent process function attached to XiaoYi channel") + } + } + enabledChannels := channelManager.GetEnabledChannels() if len(enabledChannels) > 0 { fmt.Printf("✓ Channels enabled: %s\n", enabledChannels) diff --git a/go.mod b/go.mod index 98e20d07d..d06278519 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/ystyle/xiaoyi-agent-sdk v0.0.0-20260226183955-ed58f2e4fcdb // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index abbb11cd6..b43c09df5 100644 --- a/go.sum +++ b/go.sum @@ -155,6 +155,8 @@ github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpB github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= 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-20260226183955-ed58f2e4fcdb h1:sClN2nhVPsBA/nudY2S6ZN8Hz1v89qQVZpHi2SNTQxA= +github.com/ystyle/xiaoyi-agent-sdk v0.0.0-20260226183955-ed58f2e4fcdb/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 75edaf49e..95912bf0f 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -202,6 +202,19 @@ func (m *Manager) initChannels() error { } } + if m.config.Channels.XiaoYi.Enabled && m.config.Channels.XiaoYi.AK != "" { + logger.DebugC("channels", "Attempting to initialize XiaoYi channel") + xiaoyi, err := NewXiaoYiChannel(m.config.Channels.XiaoYi, m.bus) + if err != nil { + logger.ErrorCF("channels", "Failed to initialize XiaoYi channel", map[string]any{ + "error": err.Error(), + }) + } else { + m.channels["xiaoyi"] = xiaoyi + logger.InfoC("channels", "XiaoYi channel enabled successfully") + } + } + logger.InfoCF("channels", "Channel initialization completed", map[string]any{ "enabled_channels": len(m.channels), }) diff --git a/pkg/channels/xiaoyi.go b/pkg/channels/xiaoyi.go new file mode 100644 index 000000000..9f3808c77 --- /dev/null +++ b/pkg/channels/xiaoyi.go @@ -0,0 +1,153 @@ +package channels + +import ( + "context" + "fmt" + "strings" + "sync" + + 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/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +type ProcessFunc func(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) + +type XiaoYiChannel struct { + *BaseChannel + config config.XiaoYiConfig + client xiaoyi.Client + processFunc ProcessFunc + ctx context.Context + cancel context.CancelFunc + mu sync.RWMutex +} + +func NewXiaoYiChannel(cfg config.XiaoYiConfig, messageBus *bus.MessageBus) (*XiaoYiChannel, error) { + base := NewBaseChannel("xiaoyi", cfg, messageBus, cfg.AllowFrom) + + return &XiaoYiChannel{ + BaseChannel: base, + config: cfg, + }, nil +} + +func (c *XiaoYiChannel) SetProcessFunc(fn ProcessFunc) { + c.mu.Lock() + defer c.mu.Unlock() + c.processFunc = fn +} + +func (c *XiaoYiChannel) Start(ctx context.Context) error { + if c.config.AK == "" || c.config.SK == "" || c.config.AgentID == "" { + return fmt.Errorf("xiaoyi ak, sk and agent_id are required") + } + + logger.InfoC("xiaoyi", "Starting XiaoYi channel") + + cfg := &types.Config{ + AK: c.config.AK, + SK: c.config.SK, + AgentID: c.config.AgentID, + WSUrl1: c.config.WSUrl1, + WSUrl2: c.config.WSUrl2, + SingleServer: true, + } + + c.client = xiaoyi.New(cfg) + + c.client.OnMessage(func(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, + }) + + chatID := fmt.Sprintf("%s:%s", sessionID, taskID) + + metadata := map[string]string{ + "session_id": sessionID, + "task_id": taskID, + } + + c.HandleMessage(sessionID, chatID, text, []string{}, metadata) + + return c.client.SendStatus(ctx, taskID, sessionID, "处理中...") + }) + + 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 cancelled", 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 fmt.Errorf("xiaoyi channel not running") + } + + parts := strings.SplitN(msg.ChatID, ":", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid chat_id format, expected sessionID:taskID") + } + + sessionID := parts[0] + taskID := parts[1] + + logger.InfoCF("xiaoyi", "Sending message", map[string]any{ + "session": sessionID, + "task": taskID, + "length": len(msg.Content), + }) + + return c.client.Reply(ctx, taskID, sessionID, msg.Content) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index ca5803c35..143060abc 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -203,6 +203,7 @@ type ChannelsConfig struct { OneBot OneBotConfig `json:"onebot"` WeCom WeComConfig `json:"wecom"` WeComApp WeComAppConfig `json:"wecom_app"` + XiaoYi XiaoYiConfig `json:"xiaoyi"` } type WhatsAppConfig struct { @@ -307,6 +308,16 @@ type WeComAppConfig struct { ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"` } +type XiaoYiConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_XIAOYI_ENABLED"` + WSUrl1 string `json:"ws_url1" env:"PICOCLAW_CHANNELS_XIAOYI_WS_URL1"` + WSUrl2 string `json:"ws_url2" env:"PICOCLAW_CHANNELS_XIAOYI_WS_URL2"` + 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"` + 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