diff --git a/.gitignore b/.gitignore
index ce30d749e9..5df380ebfa 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 a5ad4a02dc..2b296eea73 100644
--- a/Makefile
+++ b/Makefile
@@ -119,6 +119,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 7bc7b1089d..80c3a43622 100644
--- a/README.md
+++ b/README.md
@@ -264,16 +264,17 @@ That's it! You have a working AI assistant in 2 minutes.
## 💬 Chat Apps
-Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
-
-| 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) |
-| **WeCom** | Medium (CorpID + webhook setup) |
+Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, WeCom 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) |
+| **WebSocket** | Easy (local LAN web chat) |
+| **WeCom** | Medium (CorpID + webhook setup) |
Telegram (Recommended)
@@ -559,6 +560,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 4d739c5eb4..0a90ec3b03 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -297,6 +297,65 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
+
+
+
+
+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/S99picoclaw_gateway b/S99picoclaw_gateway
new file mode 100644
index 0000000000..2de90ac2f7
--- /dev/null
+++ b/S99picoclaw_gateway
@@ -0,0 +1,52 @@
+#!/bin/sh
+PATH=/usr/sbin:/usr/bin:/sbin:/bin
+DAEMON="/root/picoclaw"
+DAEMON_ARGS="gateway"
+LOG="/var/log/picoclaw_gateway.log"
+PIDFILE="/var/run/picoclaw_gateway.pid"
+RUN_USER="root"
+
+start() {
+ [ -x "$DAEMON" ] || { echo "picoclaw 未找到"; exit 1; }
+ mkdir -p /var/run
+ printf "启动 picoclaw: "
+ su - $RUN_USER -c "\"$DAEMON\" $DAEMON_ARGS >\"$LOG\" 2>&1 & echo \$! >\"$PIDFILE\""
+ sleep 1
+ if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
+ echo "OK"
+ else
+ echo "失败"
+ rm -f "$PIDFILE"
+ exit 1
+ fi
+}
+
+stop() {
+ printf "停止 picoclaw: "
+ if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
+ kill "$(cat "$PIDFILE")" 2>/dev/null || true
+ sleep 2
+ kill -0 "$(cat "$PIDFILE")" 2>/dev/null && kill -9 "$(cat "$PIDFILE")" 2>/dev/null || true
+ rm -f "$PIDFILE"
+ echo "OK"
+ else
+ su - $RUN_USER -c "killall picoclaw 2>/dev/null" && echo "OK" || echo "进程未运行"
+ fi
+}
+
+status() {
+ if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
+ echo "picoclaw 正在运行(pid $(cat "$PIDFILE"))"
+ else
+ echo "picoclaw 已停止"; exit 1
+ fi
+}
+
+case "$1" in
+ start) start ;;
+ stop) stop ;;
+ restart|reload) stop; start ;;
+ status) status ;;
+ *) echo "用法: $0 {start|stop|restart|status}"; exit 1 ;;
+esac
+exit 0
diff --git a/cmd/picoclaw/cmd_status.go b/cmd/picoclaw/cmd_status.go
index 07296784ea..d3f89d0b40 100644
--- a/cmd/picoclaw/cmd_status.go
+++ b/cmd/picoclaw/cmd_status.go
@@ -5,7 +5,10 @@ package main
import (
"fmt"
+ "net"
"os"
+ "strconv"
+ "strings"
"github.com/sipeed/picoclaw/pkg/auth"
)
@@ -43,19 +46,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 +131,37 @@ 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 {
+ hostPort := net.JoinHostPort(cfg.Channels.WebSocket.Host, strconv.Itoa(cfg.Channels.WebSocket.Port))
+ addr := "http://" + hostPort
+ channelStatus(true, "WebSocket", addr)
+ } else {
+ channelStatus(false, "WebSocket")
+ }
}
}
diff --git a/config/config.example.json b/config/config.example.json
index 77a8c06834..bba16df121 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -114,6 +114,12 @@
"group_trigger_prefix": [],
"allow_from": []
},
+ "websocket": {
+ "enabled": false,
+ "host": "0.0.0.0",
+ "port": 8080,
+ "allow_from": []
+ },
"wecom": {
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
"enabled": false,
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 75edaf49e3..be2db8f373 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"
@@ -202,6 +203,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]any{
+ "error": err.Error(),
+ })
+ } else {
+ m.channels["websocket"] = ws
+ logger.InfoC("channels", "WebSocket channel enabled successfully")
+ }
+ }
+
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
"enabled_channels": len(m.channels),
})
diff --git a/pkg/channels/websocket/channel.go b/pkg/channels/websocket/channel.go
new file mode 100644
index 0000000000..e06bea39f1
--- /dev/null
+++ b/pkg/channels/websocket/channel.go
@@ -0,0 +1,417 @@
+// 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]any{
+ "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]any{
+ "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 any) 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]any{
+ "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]any{
+ "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]any{
+ "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([]any, 0)
+ c.clients.Range(func(key, value any) 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]any{
+ "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]any{
+ "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]any{
+ "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]any{
+ "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]any{
+ "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]any{
+ "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]any{
+ "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]any{
+ "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]any{
+ "client_id": clientID,
+ })
+ continue
+ }
+
+ logger.DebugCF("websocket", "Received message", map[string]any{
+ "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 0000000000..a02e347e8c
--- /dev/null
+++ b/pkg/channels/websocket/chat.html
@@ -0,0 +1,1067 @@
+
+
+
+