From 980233826b6b83423c3c58c317f7653b6bde5a92 Mon Sep 17 00:00:00 2001 From: likeaturtle Date: Fri, 20 Feb 2026 22:36:15 +0800 Subject: [PATCH] add websocket channel and chat html. Fix the issue where the status command is incompatible with the new model_list --- .gitignore | 2 + Makefile | 6 + README.md | 70 +- README.zh.md | 56 +- cmd/picoclaw/cmd_status.go | 88 ++- config/config.example.json | 6 + pkg/channels/manager.go | 14 + pkg/channels/websocket/channel.go | 416 +++++++++++ pkg/channels/websocket/chat.html | 1067 +++++++++++++++++++++++++++++ pkg/channels/websocket/logo.jpg | Bin 0 -> 27614 bytes pkg/config/config.go | 28 +- pkg/config/defaults.go | 6 + 12 files changed, 1727 insertions(+), 32 deletions(-) create mode 100644 pkg/channels/websocket/channel.go create mode 100644 pkg/channels/websocket/chat.html create mode 100644 pkg/channels/websocket/logo.jpg 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. + +
+ ## 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 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 端口。 + +
+ ## ClawdChat 加入 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 @@ + + + + + + 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 0000000000000000000000000000000000000000..7f1a7c2b78fe47ee01ac09c45728febdeb6abea5 GIT binary patch literal 27614 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 p|7%f>CYNE{