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. + +
+ ## ClawdChat 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 端口。 + +
+ + + ## ClawdChat 加入 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 @@ + + + + + + PicoClaw · Intelligent Conversation + + + + + + + +
+
+
+ +

PicoClaw WebSocket Chat

+
+
+
+
+ Connecting... +
+
+ EN +
+
+ +
+
+
+ +
+
+
+
+
+
+
+ +
+
+ +
+
+ + + +
+ +
+
+
+
+ + + + + + diff --git a/pkg/channels/websocket/logo.jpg b/pkg/channels/websocket/logo.jpg new file mode 100644 index 0000000000..7f1a7c2b78 Binary files /dev/null and b/pkg/channels/websocket/logo.jpg differ diff --git a/pkg/config/config.go b/pkg/config/config.go index 20556011a1..b75370e0e8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -180,18 +180,19 @@ type AgentDefaults struct { } type ChannelsConfig struct { - WhatsApp WhatsAppConfig `json:"whatsapp"` - Telegram TelegramConfig `json:"telegram"` - Feishu FeishuConfig `json:"feishu"` - Discord DiscordConfig `json:"discord"` - MaixCam MaixCamConfig `json:"maixcam"` - QQ QQConfig `json:"qq"` - DingTalk DingTalkConfig `json:"dingtalk"` - Slack SlackConfig `json:"slack"` - LINE LINEConfig `json:"line"` - OneBot OneBotConfig `json:"onebot"` - WeCom WeComConfig `json:"wecom"` - WeComApp WeComAppConfig `json:"wecom_app"` + WhatsApp WhatsAppConfig `json:"whatsapp"` + Telegram TelegramConfig `json:"telegram"` + Feishu FeishuConfig `json:"feishu"` + Discord DiscordConfig `json:"discord"` + MaixCam MaixCamConfig `json:"maixcam"` + QQ QQConfig `json:"qq"` + DingTalk DingTalkConfig `json:"dingtalk"` + Slack SlackConfig `json:"slack"` + LINE LINEConfig `json:"line"` + OneBot OneBotConfig `json:"onebot"` + WebSocket WebSocketConfig `json:"websocket"` + WeCom WeComConfig `json:"wecom"` + WeComApp WeComAppConfig `json:"wecom_app"` } type WhatsAppConfig struct { @@ -296,6 +297,13 @@ type WeComAppConfig struct { ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"` } +type WebSocketConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEBSOCKET_ENABLED"` + Host string `json:"host" env:"PICOCLAW_CHANNELS_WEBSOCKET_HOST"` + Port int `json:"port" env:"PICOCLAW_CHANNELS_WEBSOCKET_PORT"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WEBSOCKET_ALLOW_FROM"` +} + type HeartbeatConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 7654326e76..5b906b0c30 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -113,6 +113,12 @@ func DefaultConfig() *Config { AllowFrom: FlexibleStringSlice{}, ReplyTimeout: 5, }, + WebSocket: WebSocketConfig{ + Enabled: false, + Host: "0.0.0.0", + Port: 8080, + AllowFrom: FlexibleStringSlice{}, + }, }, Providers: ProvidersConfig{ OpenAI: OpenAIProviderConfig{WebSearch: true},