add websocket channel and chat html. Fix the issue where the status command is incompatible with the new model_list
This commit is contained in:
parent
f874a3371b
commit
980233826b
12 changed files with 1727 additions and 32 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -44,3 +44,5 @@ tasks/
|
||||||
|
|
||||||
# Added by goreleaser init:
|
# Added by goreleaser init:
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
.qoder/
|
||||||
6
Makefile
6
Makefile
|
|
@ -116,6 +116,12 @@ uninstall-all:
|
||||||
@echo "Removed workspace: $(PICOCLAW_HOME)"
|
@echo "Removed workspace: $(PICOCLAW_HOME)"
|
||||||
@echo "Complete uninstallation done!"
|
@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: Remove build artifacts
|
||||||
clean:
|
clean:
|
||||||
@echo "Cleaning build artifacts..."
|
@echo "Cleaning build artifacts..."
|
||||||
|
|
|
||||||
70
README.md
70
README.md
|
|
@ -264,15 +264,16 @@ That's it! You have a working AI assistant in 2 minutes.
|
||||||
|
|
||||||
## 💬 Chat Apps
|
## 💬 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 |
|
| Channel | Setup |
|
||||||
| ------------ | ---------------------------------- |
|
| ------------- | ---------------------------------- |
|
||||||
| **Telegram** | Easy (just a token) |
|
| **Telegram** | Easy (just a token) |
|
||||||
| **Discord** | Easy (bot token + intents) |
|
| **Discord** | Easy (bot token + intents) |
|
||||||
| **QQ** | Easy (AppID + AppSecret) |
|
| **QQ** | Easy (AppID + AppSecret) |
|
||||||
| **DingTalk** | Medium (app credentials) |
|
| **DingTalk** | Medium (app credentials) |
|
||||||
| **LINE** | Medium (credentials + webhook URL) |
|
| **LINE** | Medium (credentials + webhook URL) |
|
||||||
|
| **WebSocket** | Easy (local LAN web chat) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recommended)</summary>
|
<summary><b>Telegram</b> (Recommended)</summary>
|
||||||
|
|
@ -477,6 +478,59 @@ picoclaw gateway
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>WebSocket</b> (Local LAN Web Chat)</summary>
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="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.
|
Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.
|
||||||
|
|
|
||||||
56
README.zh.md
56
README.zh.md
|
|
@ -273,7 +273,7 @@ picoclaw agent -m "2+2 等于几?"
|
||||||
|
|
||||||
## 💬 聊天应用集成 (Chat Apps)
|
## 💬 聊天应用集成 (Chat Apps)
|
||||||
|
|
||||||
通过 Telegram, Discord 或钉钉与您的 PicoClaw 对话。
|
通过 Telegram, Discord, 钉钉或 WebSocket 与您的 PicoClaw 对话。
|
||||||
|
|
||||||
| 渠道 | 设置难度 |
|
| 渠道 | 设置难度 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|
@ -281,6 +281,7 @@ picoclaw agent -m "2+2 等于几?"
|
||||||
| **Discord** | 简单 (bot token + intents) |
|
| **Discord** | 简单 (bot token + intents) |
|
||||||
| **QQ** | 简单 (AppID + AppSecret) |
|
| **QQ** | 简单 (AppID + AppSecret) |
|
||||||
| **钉钉 (DingTalk)** | 中等 (app credentials) |
|
| **钉钉 (DingTalk)** | 中等 (app credentials) |
|
||||||
|
| **WebSocket** | 简单 (局域网 Web 聊天) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (推荐)</summary>
|
<summary><b>Telegram</b> (推荐)</summary>
|
||||||
|
|
@ -438,6 +439,59 @@ picoclaw gateway
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>WebSocket</b> (局域网 Web 聊天)</summary>
|
||||||
|
|
||||||
|
**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 端口。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||||
|
|
||||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ package main
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
)
|
)
|
||||||
|
|
@ -43,19 +44,49 @@ func statusCmd() {
|
||||||
if _, err := os.Stat(configPath); err == nil {
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model)
|
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model)
|
||||||
|
|
||||||
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
|
// Build a map of providers from model_list
|
||||||
hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
|
modelProviders := make(map[string]bool)
|
||||||
hasOpenAI := cfg.Providers.OpenAI.APIKey != ""
|
for _, model := range cfg.ModelList {
|
||||||
hasGemini := cfg.Providers.Gemini.APIKey != ""
|
if model.APIKey == "" {
|
||||||
hasZhipu := cfg.Providers.Zhipu.APIKey != ""
|
continue
|
||||||
hasQwen := cfg.Providers.Qwen.APIKey != ""
|
}
|
||||||
hasGroq := cfg.Providers.Groq.APIKey != ""
|
modelStr := strings.ToLower(model.Model)
|
||||||
hasVLLM := cfg.Providers.VLLM.APIBase != ""
|
// Extract provider name (before "/")
|
||||||
hasMoonshot := cfg.Providers.Moonshot.APIKey != ""
|
if idx := strings.Index(modelStr, "/"); idx > 0 {
|
||||||
hasDeepSeek := cfg.Providers.DeepSeek.APIKey != ""
|
provider := modelStr[:idx]
|
||||||
hasVolcEngine := cfg.Providers.VolcEngine.APIKey != ""
|
modelProviders[provider] = true
|
||||||
hasNvidia := cfg.Providers.Nvidia.APIKey != ""
|
// Add aliases
|
||||||
hasOllama := cfg.Providers.Ollama.APIBase != ""
|
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 {
|
status := func(enabled bool) string {
|
||||||
if enabled {
|
if enabled {
|
||||||
|
|
@ -98,5 +129,36 @@ func statusCmd() {
|
||||||
fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status)
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,12 @@
|
||||||
"reconnect_interval": 5,
|
"reconnect_interval": 5,
|
||||||
"group_trigger_prefix": [],
|
"group_trigger_prefix": [],
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
},
|
||||||
|
"websocket": {
|
||||||
|
"enabled": false,
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 8080,
|
||||||
|
"allow_from": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels/websocket"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/constants"
|
"github.com/sipeed/picoclaw/pkg/constants"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"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{}{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
||||||
"enabled_channels": len(m.channels),
|
"enabled_channels": len(m.channels),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
416
pkg/channels/websocket/channel.go
Normal file
416
pkg/channels/websocket/channel.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
1067
pkg/channels/websocket/chat.html
Normal file
1067
pkg/channels/websocket/chat.html
Normal file
File diff suppressed because it is too large
Load diff
BIN
pkg/channels/websocket/logo.jpg
Normal file
BIN
pkg/channels/websocket/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
|
|
@ -180,16 +180,17 @@ type AgentDefaults struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelsConfig struct {
|
type ChannelsConfig struct {
|
||||||
WhatsApp WhatsAppConfig `json:"whatsapp"`
|
WhatsApp WhatsAppConfig `json:"whatsapp"`
|
||||||
Telegram TelegramConfig `json:"telegram"`
|
Telegram TelegramConfig `json:"telegram"`
|
||||||
Feishu FeishuConfig `json:"feishu"`
|
Feishu FeishuConfig `json:"feishu"`
|
||||||
Discord DiscordConfig `json:"discord"`
|
Discord DiscordConfig `json:"discord"`
|
||||||
MaixCam MaixCamConfig `json:"maixcam"`
|
MaixCam MaixCamConfig `json:"maixcam"`
|
||||||
QQ QQConfig `json:"qq"`
|
QQ QQConfig `json:"qq"`
|
||||||
DingTalk DingTalkConfig `json:"dingtalk"`
|
DingTalk DingTalkConfig `json:"dingtalk"`
|
||||||
Slack SlackConfig `json:"slack"`
|
Slack SlackConfig `json:"slack"`
|
||||||
LINE LINEConfig `json:"line"`
|
LINE LINEConfig `json:"line"`
|
||||||
OneBot OneBotConfig `json:"onebot"`
|
OneBot OneBotConfig `json:"onebot"`
|
||||||
|
WebSocket WebSocketConfig `json:"websocket"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WhatsAppConfig struct {
|
type WhatsAppConfig struct {
|
||||||
|
|
@ -268,6 +269,13 @@ type OneBotConfig struct {
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
type HeartbeatConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,12 @@ func DefaultConfig() *Config {
|
||||||
GroupTriggerPrefix: []string{},
|
GroupTriggerPrefix: []string{},
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
|
WebSocket: WebSocketConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Host: "0.0.0.0",
|
||||||
|
Port: 8080,
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: ProvidersConfig{
|
||||||
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue