diff --git a/.gitignore b/.gitignore
index ce30d749e..5df380ebf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,3 +44,5 @@ tasks/
# Added by goreleaser init:
dist/
+
+.qoder/
\ No newline at end of file
diff --git a/Makefile b/Makefile
index ff280e3e4..031f34c55 100644
--- a/Makefile
+++ b/Makefile
@@ -116,6 +116,12 @@ uninstall-all:
@echo "Removed workspace: $(PICOCLAW_HOME)"
@echo "Complete uninstallation done!"
+## build-riscv64: Build picoclaw for RISC-V 64-bit
+build-riscv64: generate
+ @mkdir -p $(BUILD_DIR)
+ GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
+ @echo "RISC-V build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64"
+
## clean: Remove build artifacts
clean:
@echo "Cleaning build artifacts..."
diff --git a/README.md b/README.md
index a9065d2a4..b888fc61a 100644
--- a/README.md
+++ b/README.md
@@ -264,15 +264,16 @@ That's it! You have a working AI assistant in 2 minutes.
## 💬 Chat Apps
-Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
+Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WebSocket
-| Channel | Setup |
-| ------------ | ---------------------------------- |
-| **Telegram** | Easy (just a token) |
-| **Discord** | Easy (bot token + intents) |
-| **QQ** | Easy (AppID + AppSecret) |
-| **DingTalk** | Medium (app credentials) |
-| **LINE** | Medium (credentials + webhook URL) |
+| Channel | Setup |
+| ------------- | ---------------------------------- |
+| **Telegram** | Easy (just a token) |
+| **Discord** | Easy (bot token + intents) |
+| **QQ** | Easy (AppID + AppSecret) |
+| **DingTalk** | Medium (app credentials) |
+| **LINE** | Medium (credentials + webhook URL) |
+| **WebSocket** | Easy (local LAN web chat) |
Telegram (Recommended)
@@ -477,6 +478,59 @@ picoclaw gateway
WebSocket (Local LAN Web Chat)
+
+**1. Configure**
+
+WebSocket channel provides a web-based chat interface accessible from your local network:
+
+```json
+{
+ "channels": {
+ "websocket": {
+ "enabled": true,
+ "host": "0.0.0.0",
+ "port": 8080,
+ "allow_from": []
+ }
+ }
+}
+```
+
+**Configuration Options:**
+
+- `host`: Bind address (use `0.0.0.0` to allow connections from LAN, or `127.0.0.1` for localhost only)
+- `port`: HTTP server port (default: 8080)
+- `allow_from`: List of allowed client IPs (empty list = allow all)
+
+**2. Run**
+
+```bash
+picoclaw gateway
+```
+
+**3. Access the Web Interface**
+
+Open your browser and navigate to:
+
+- Local: `http://localhost:8080`
+- LAN: `http://YOUR_SERVER_IP:8080`
+
+The chat interface features:
+
+- Modern, responsive design with dark mode
+- Real-time messaging via WebSocket
+- Automatic reconnection on connection loss
+- Connection status indicator
+- Multi-language support (English/中文)
+
+> **Note**: The WebSocket channel is designed for local LAN use. For internet-facing deployments, consider setting up proper authentication and using HTTPS with a reverse proxy.
+
+> **Docker Compose**: Add `ports: ["8080:8080"]` to the `picoclaw-gateway` service to expose the WebSocket port.
+
+
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.
diff --git a/README.zh.md b/README.zh.md
index 4827e66ea..4ce2dd832 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -273,7 +273,7 @@ picoclaw agent -m "2+2 等于几?"
## 💬 聊天应用集成 (Chat Apps)
-通过 Telegram, Discord 或钉钉与您的 PicoClaw 对话。
+通过 Telegram, Discord, 钉钉或 WebSocket 与您的 PicoClaw 对话。
| 渠道 | 设置难度 |
| --- | --- |
@@ -281,6 +281,7 @@ picoclaw agent -m "2+2 等于几?"
| **Discord** | 简单 (bot token + intents) |
| **QQ** | 简单 (AppID + AppSecret) |
| **钉钉 (DingTalk)** | 中等 (app credentials) |
+| **WebSocket** | 简单 (局域网 Web 聊天) |
Telegram (推荐)
@@ -438,6 +439,59 @@ picoclaw gateway
WebSocket (局域网 Web 聊天)
+
+**1. 配置**
+
+WebSocket 频道提供了一个可从局域网访问的 Web 聊天界面:
+
+```json
+{
+ "channels": {
+ "websocket": {
+ "enabled": true,
+ "host": "0.0.0.0",
+ "port": 8080,
+ "allow_from": []
+ }
+ }
+}
+```
+
+**配置选项:**
+
+- `host`: 绑定地址(使用 `0.0.0.0` 允许局域网连接,或 `127.0.0.1` 仅允许本地连接)
+- `port`: HTTP 服务器端口(默认:8080)
+- `allow_from`: 允许的客户端 IP 列表(空列表 = 允许所有)
+
+**2. 运行**
+
+```bash
+picoclaw gateway
+```
+
+**3. 访问 Web 界面**
+
+打开浏览器并访问:
+
+- 本地:`http://localhost:8080`
+- 局域网:`http://您的服务器IP:8080`
+
+聊天界面功能:
+
+- 现代化响应式设计,支持深色模式
+- 通过 WebSocket 实现实时消息传输
+- 连接丢失时自动重连
+- 连接状态指示器
+- 多语言支持(中文/English)
+
+> **注意**:WebSocket 频道设计用于局域网使用。如果需要通过互联网访问,请考虑设置适当的身份验证,并使用反向代理配置 HTTPS。
+
+> **Docker Compose**:在 `picoclaw-gateway` 服务中添加 `ports: ["8080:8080"]` 以暴露 WebSocket 端口。
+
+
加入 Agent 社交网络
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
diff --git a/cmd/picoclaw/cmd_status.go b/cmd/picoclaw/cmd_status.go
index 07296784e..c19e3d8fd 100644
--- a/cmd/picoclaw/cmd_status.go
+++ b/cmd/picoclaw/cmd_status.go
@@ -6,6 +6,7 @@ package main
import (
"fmt"
"os"
+ "strings"
"github.com/sipeed/picoclaw/pkg/auth"
)
@@ -43,19 +44,49 @@ func statusCmd() {
if _, err := os.Stat(configPath); err == nil {
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model)
- hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
- hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
- hasOpenAI := cfg.Providers.OpenAI.APIKey != ""
- hasGemini := cfg.Providers.Gemini.APIKey != ""
- hasZhipu := cfg.Providers.Zhipu.APIKey != ""
- hasQwen := cfg.Providers.Qwen.APIKey != ""
- hasGroq := cfg.Providers.Groq.APIKey != ""
- hasVLLM := cfg.Providers.VLLM.APIBase != ""
- hasMoonshot := cfg.Providers.Moonshot.APIKey != ""
- hasDeepSeek := cfg.Providers.DeepSeek.APIKey != ""
- hasVolcEngine := cfg.Providers.VolcEngine.APIKey != ""
- hasNvidia := cfg.Providers.Nvidia.APIKey != ""
- hasOllama := cfg.Providers.Ollama.APIBase != ""
+ // Build a map of providers from model_list
+ modelProviders := make(map[string]bool)
+ for _, model := range cfg.ModelList {
+ if model.APIKey == "" {
+ continue
+ }
+ modelStr := strings.ToLower(model.Model)
+ // Extract provider name (before "/")
+ if idx := strings.Index(modelStr, "/"); idx > 0 {
+ provider := modelStr[:idx]
+ modelProviders[provider] = true
+ // Add aliases
+ switch provider {
+ case "doubao":
+ modelProviders["volcengine"] = true
+ case "claude":
+ modelProviders["anthropic"] = true
+ case "gpt":
+ modelProviders["openai"] = true
+ case "tongyi":
+ modelProviders["qwen"] = true
+ case "kimi":
+ modelProviders["moonshot"] = true
+ case "glm":
+ modelProviders["zhipu"] = true
+ }
+ }
+ }
+
+ // Check providers (legacy) or model_list (new)
+ hasOpenRouter := cfg.Providers.OpenRouter.APIKey != "" || modelProviders["openrouter"]
+ hasAnthropic := cfg.Providers.Anthropic.APIKey != "" || modelProviders["anthropic"]
+ hasOpenAI := cfg.Providers.OpenAI.APIKey != "" || modelProviders["openai"]
+ hasGemini := cfg.Providers.Gemini.APIKey != "" || modelProviders["gemini"]
+ hasZhipu := cfg.Providers.Zhipu.APIKey != "" || modelProviders["zhipu"]
+ hasQwen := cfg.Providers.Qwen.APIKey != "" || modelProviders["qwen"]
+ hasGroq := cfg.Providers.Groq.APIKey != "" || modelProviders["groq"]
+ hasVLLM := cfg.Providers.VLLM.APIBase != "" || modelProviders["vllm"]
+ hasMoonshot := cfg.Providers.Moonshot.APIKey != "" || modelProviders["moonshot"]
+ hasDeepSeek := cfg.Providers.DeepSeek.APIKey != "" || modelProviders["deepseek"]
+ hasVolcEngine := cfg.Providers.VolcEngine.APIKey != "" || modelProviders["volcengine"]
+ hasNvidia := cfg.Providers.Nvidia.APIKey != "" || modelProviders["nvidia"]
+ hasOllama := cfg.Providers.Ollama.APIBase != "" || modelProviders["ollama"]
status := func(enabled bool) string {
if enabled {
@@ -98,5 +129,36 @@ func statusCmd() {
fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status)
}
}
+
+ // Display channel status
+ fmt.Println("\nChannels:")
+ channelStatus := func(enabled bool, name string, details ...string) {
+ if enabled {
+ if len(details) > 0 {
+ fmt.Printf(" %s: ✓ %s\n", name, details[0])
+ } else {
+ fmt.Printf(" %s: ✓\n", name)
+ }
+ } else {
+ fmt.Printf(" %s: disabled\n", name)
+ }
+ }
+
+ channelStatus(cfg.Channels.Telegram.Enabled, "Telegram")
+ channelStatus(cfg.Channels.Discord.Enabled, "Discord")
+ channelStatus(cfg.Channels.Feishu.Enabled, "Feishu")
+ channelStatus(cfg.Channels.DingTalk.Enabled, "DingTalk")
+ channelStatus(cfg.Channels.Slack.Enabled, "Slack")
+ channelStatus(cfg.Channels.WhatsApp.Enabled, "WhatsApp")
+ channelStatus(cfg.Channels.QQ.Enabled, "QQ")
+ channelStatus(cfg.Channels.LINE.Enabled, "LINE")
+ channelStatus(cfg.Channels.OneBot.Enabled, "OneBot")
+ channelStatus(cfg.Channels.MaixCam.Enabled, "MaixCam")
+ if cfg.Channels.WebSocket.Enabled {
+ addr := fmt.Sprintf("http://%s:%d", cfg.Channels.WebSocket.Host, cfg.Channels.WebSocket.Port)
+ channelStatus(true, "WebSocket", addr)
+ } else {
+ channelStatus(false, "WebSocket")
+ }
}
}
diff --git a/config/config.example.json b/config/config.example.json
index 07b75d785..48533531c 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -113,6 +113,12 @@
"reconnect_interval": 5,
"group_trigger_prefix": [],
"allow_from": []
+ },
+ "websocket": {
+ "enabled": false,
+ "host": "0.0.0.0",
+ "port": 8080,
+ "allow_from": []
}
},
"providers": {
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 7f6abc4cb..805e493a1 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -12,6 +12,7 @@ import (
"sync"
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels/websocket"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger"
@@ -176,6 +177,19 @@ func (m *Manager) initChannels() error {
}
}
+ if m.config.Channels.WebSocket.Enabled {
+ logger.DebugC("channels", "Attempting to initialize WebSocket channel")
+ ws, err := websocket.NewChannel(m.config.Channels.WebSocket, m.bus)
+ if err != nil {
+ logger.ErrorCF("channels", "Failed to initialize WebSocket channel", map[string]interface{}{
+ "error": err.Error(),
+ })
+ } else {
+ m.channels["websocket"] = ws
+ logger.InfoC("channels", "WebSocket channel enabled successfully")
+ }
+ }
+
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
"enabled_channels": len(m.channels),
})
diff --git a/pkg/channels/websocket/channel.go b/pkg/channels/websocket/channel.go
new file mode 100644
index 000000000..4dca72ad8
--- /dev/null
+++ b/pkg/channels/websocket/channel.go
@@ -0,0 +1,416 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// WebSocket channel implementation for local LAN chat
+
+package websocket
+
+import (
+ "context"
+ _ "embed"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+//go:embed chat.html
+var chatHTML []byte
+
+//go:embed logo.jpg
+var logoImage []byte
+
+// Channel implements the Channel interface for WebSocket connections
+type Channel struct {
+ config config.WebSocketConfig
+ bus *bus.MessageBus
+ running bool
+ allowList []string
+ server *http.Server
+ upgrader websocket.Upgrader
+ clients sync.Map // map[string]*websocket.Conn
+ clientsMu sync.RWMutex
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+// WebSocketMessage represents the JSON message format
+type WebSocketMessage struct {
+ Type string `json:"type"` // "chat", "status", "error", "system"
+ Content string `json:"content"` // Message content
+ Sender string `json:"sender"` // Sender ID
+ Timestamp int64 `json:"timestamp"` // Unix timestamp in milliseconds
+ SessionID string `json:"session_id,omitempty"`
+}
+
+// NewChannel creates a new WebSocket channel instance
+func NewChannel(cfg config.WebSocketConfig, messageBus *bus.MessageBus) (*Channel, error) {
+ if cfg.Port == 0 {
+ cfg.Port = 8080
+ }
+ if cfg.Host == "" {
+ cfg.Host = "0.0.0.0"
+ }
+
+ return &Channel{
+ config: cfg,
+ bus: messageBus,
+ allowList: cfg.AllowFrom,
+ running: false,
+ upgrader: websocket.Upgrader{
+ CheckOrigin: func(r *http.Request) bool {
+ // Allow all origins for local LAN usage
+ // For production, you should implement proper origin checking
+ return true
+ },
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ },
+ }, nil
+}
+
+// Name returns the channel name
+func (c *Channel) Name() string {
+ return "websocket"
+}
+
+// IsRunning returns whether the channel is currently running
+func (c *Channel) IsRunning() bool {
+ c.clientsMu.RLock()
+ defer c.clientsMu.RUnlock()
+ return c.running
+}
+
+// IsAllowed checks if a sender ID is allowed to use this channel
+func (c *Channel) IsAllowed(senderID string) bool {
+ if len(c.allowList) == 0 {
+ return true
+ }
+ for _, allowed := range c.allowList {
+ if strings.EqualFold(allowed, senderID) {
+ return true
+ }
+ }
+ return false
+}
+
+// setRunning sets the running state
+func (c *Channel) setRunning(running bool) {
+ c.clientsMu.Lock()
+ defer c.clientsMu.Unlock()
+ c.running = running
+}
+
+// HandleMessage processes an incoming message and publishes it to the bus
+func (c *Channel) HandleMessage(senderID, chatID, content string, media []string, metadata map[string]string) {
+ if !c.IsAllowed(senderID) {
+ return
+ }
+
+ // Build session key: channel:chatID
+ sessionKey := fmt.Sprintf("%s:%s", c.Name(), chatID)
+
+ msg := bus.InboundMessage{
+ Channel: c.Name(),
+ SenderID: senderID,
+ ChatID: chatID,
+ Content: content,
+ Media: media,
+ SessionKey: sessionKey,
+ Metadata: metadata,
+ }
+
+ c.bus.PublishInbound(msg)
+}
+
+// Start initializes and starts the WebSocket server
+func (c *Channel) Start(ctx context.Context) error {
+ c.ctx, c.cancel = context.WithCancel(ctx)
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/ws", c.handleWebSocket)
+ mux.HandleFunc("/", c.handleIndex)
+ mux.HandleFunc("/assets/logo.jpg", c.handleLogo)
+
+ addr := fmt.Sprintf("%s:%d", c.config.Host, c.config.Port)
+ c.server = &http.Server{
+ Addr: addr,
+ Handler: mux,
+ ReadTimeout: 15 * time.Second,
+ WriteTimeout: 15 * time.Second,
+ }
+
+ c.setRunning(true)
+ logger.InfoCF("websocket", "WebSocket channel starting", map[string]interface{}{
+ "address": addr,
+ })
+
+ // Start server in goroutine
+ errCh := make(chan error, 1)
+ go func() {
+ if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ errCh <- err
+ }
+ }()
+
+ // Check for immediate startup errors
+ select {
+ case err := <-errCh:
+ c.setRunning(false)
+ return fmt.Errorf("failed to start WebSocket server: %w", err)
+ case <-time.After(100 * time.Millisecond):
+ logger.InfoCF("websocket", "WebSocket channel started successfully", map[string]interface{}{
+ "address": addr,
+ })
+ return nil
+ }
+}
+
+// Stop gracefully shuts down the WebSocket server
+func (c *Channel) Stop(ctx context.Context) error {
+ logger.InfoC("websocket", "Stopping WebSocket channel")
+
+ if c.cancel != nil {
+ c.cancel()
+ }
+
+ // Close all client connections
+ c.clients.Range(func(key, value interface{}) bool {
+ if conn, ok := value.(*websocket.Conn); ok {
+ conn.Close()
+ }
+ c.clients.Delete(key)
+ return true
+ })
+
+ // Shutdown HTTP server
+ if c.server != nil {
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := c.server.Shutdown(shutdownCtx); err != nil {
+ logger.ErrorCF("websocket", "Error shutting down server", map[string]interface{}{
+ "error": err.Error(),
+ })
+ }
+ }
+
+ c.setRunning(false)
+ logger.InfoC("websocket", "WebSocket channel stopped")
+ return nil
+}
+
+// Send sends a message to the specified chat (client)
+func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if !c.IsRunning() {
+ return fmt.Errorf("websocket channel not running")
+ }
+
+ wsMsg := WebSocketMessage{
+ Type: "chat",
+ Content: msg.Content,
+ Sender: "assistant",
+ Timestamp: time.Now().UnixMilli(),
+ }
+
+ data, err := json.Marshal(wsMsg)
+ if err != nil {
+ return fmt.Errorf("failed to marshal message: %w", err)
+ }
+
+ // Ensure UTF-8 validity
+ if !json.Valid(data) {
+ logger.ErrorCF("websocket", "Invalid JSON data", map[string]interface{}{
+ "content_preview": msg.Content[:min(len(msg.Content), 100)],
+ })
+ return fmt.Errorf("invalid JSON message")
+ }
+
+ // Send to specific client or broadcast
+ if msg.ChatID != "" && msg.ChatID != "broadcast" {
+ // Send to specific client
+ if conn, ok := c.clients.Load(msg.ChatID); ok {
+ if wsConn, ok := conn.(*websocket.Conn); ok {
+ err := wsConn.WriteMessage(websocket.TextMessage, data)
+ if err != nil {
+ // Connection may be dead, clean it up
+ logger.WarnCF("websocket", "Failed to send to client, removing connection", map[string]interface{}{
+ "client": msg.ChatID,
+ "error": err.Error(),
+ })
+ c.clients.Delete(msg.ChatID)
+ return err
+ }
+ return nil
+ }
+ }
+ return fmt.Errorf("client %s not found", msg.ChatID)
+ }
+
+ // Broadcast to all connected clients
+ var lastErr error
+ deadClients := make([]interface{}, 0)
+ c.clients.Range(func(key, value interface{}) bool {
+ if conn, ok := value.(*websocket.Conn); ok {
+ if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
+ logger.WarnCF("websocket", "Failed to send to client", map[string]interface{}{
+ "client": key,
+ "error": err.Error(),
+ })
+ deadClients = append(deadClients, key)
+ lastErr = err
+ }
+ }
+ return true
+ })
+
+ // Clean up dead connections
+ for _, key := range deadClients {
+ c.clients.Delete(key)
+ logger.InfoCF("websocket", "Removed dead client connection", map[string]interface{}{
+ "client": key,
+ })
+ }
+
+ return lastErr
+}
+
+// Helper function for min
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+// handleWebSocket handles WebSocket connection upgrades
+func (c *Channel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
+ conn, err := c.upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ logger.ErrorCF("websocket", "Failed to upgrade connection", map[string]interface{}{
+ "error": err.Error(),
+ })
+ return
+ }
+
+ // Generate client ID from remote address
+ clientID := r.RemoteAddr
+ c.clients.Store(clientID, conn)
+
+ logger.InfoCF("websocket", "New client connected", map[string]interface{}{
+ "client_id": clientID,
+ })
+
+ // Handle client messages
+ go c.handleClient(clientID, conn)
+}
+
+// handleClient processes messages from a WebSocket client
+func (c *Channel) handleClient(clientID string, conn *websocket.Conn) {
+ defer func() {
+ conn.Close()
+ c.clients.Delete(clientID)
+ logger.InfoCF("websocket", "Client disconnected", map[string]interface{}{
+ "client_id": clientID,
+ })
+ }()
+
+ // Set up ping/pong handlers for connection health check
+ conn.SetReadDeadline(time.Now().Add(60 * time.Second))
+ conn.SetPongHandler(func(string) error {
+ conn.SetReadDeadline(time.Now().Add(60 * time.Second))
+ return nil
+ })
+
+ // Start ping ticker
+ pingTicker := time.NewTicker(30 * time.Second)
+ defer pingTicker.Stop()
+
+ // Channel for messages
+ messageChan := make(chan []byte, 10)
+ defer close(messageChan)
+
+ // Read messages in a goroutine
+ go func() {
+ for {
+ _, message, err := conn.ReadMessage()
+ if err != nil {
+ if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
+ logger.ErrorCF("websocket", "WebSocket error", map[string]interface{}{
+ "client_id": clientID,
+ "error": err.Error(),
+ })
+ }
+ return
+ }
+ messageChan <- message
+ }
+ }()
+
+ for {
+ select {
+ case <-c.ctx.Done():
+ return
+ case <-pingTicker.C:
+ // Send ping to check connection health
+ if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil {
+ logger.WarnCF("websocket", "Failed to send ping", map[string]interface{}{
+ "client_id": clientID,
+ "error": err.Error(),
+ })
+ return
+ }
+ case message, ok := <-messageChan:
+ if !ok {
+ return
+ }
+
+ var wsMsg WebSocketMessage
+ if err := json.Unmarshal(message, &wsMsg); err != nil {
+ logger.WarnCF("websocket", "Failed to parse message", map[string]interface{}{
+ "client_id": clientID,
+ "error": err.Error(),
+ })
+ continue
+ }
+
+ // Process chat messages
+ if wsMsg.Type == "chat" {
+ // Check allowlist
+ if !c.IsAllowed(clientID) {
+ logger.WarnCF("websocket", "Unauthorized client", map[string]interface{}{
+ "client_id": clientID,
+ })
+ continue
+ }
+
+ logger.DebugCF("websocket", "Received message", map[string]interface{}{
+ "client_id": clientID,
+ "content": wsMsg.Content,
+ })
+
+ // Send to agent via message bus
+ c.HandleMessage(clientID, clientID, wsMsg.Content, nil, nil)
+ }
+ }
+ }
+}
+
+// handleLogo serves the logo image
+func (c *Channel) handleLogo(w http.ResponseWriter, r *http.Request) {
+ // Use embedded logo image
+ w.Header().Set("Content-Type", "image/jpeg")
+ w.Header().Set("Cache-Control", "public, max-age=86400") // Cache for 1 day
+ w.Write(logoImage)
+}
+
+// handleIndex serves a simple HTML chat interface
+func (c *Channel) handleIndex(w http.ResponseWriter, r *http.Request) {
+ // Use embedded HTML file
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write(chatHTML)
+}
diff --git a/pkg/channels/websocket/chat.html b/pkg/channels/websocket/chat.html
new file mode 100644
index 000000000..a02e347e8
--- /dev/null
+++ b/pkg/channels/websocket/chat.html
@@ -0,0 +1,1067 @@
+
+
+
+