feat: add HTTP channel with WebSocket support

This commit is contained in:
JaviLib 2026-02-22 03:33:57 +01:00
parent b9a66248d8
commit efeedfcaae
5 changed files with 613 additions and 0 deletions

275
pkg/channels/http.go Normal file
View file

@ -0,0 +1,275 @@
package channels
import (
"context"
"embed"
"encoding/json"
"fmt"
"html/template"
"net/http"
"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 http_templates/*
var httpTemplates embed.FS
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
type HTTPChannel struct {
*BaseChannel
server *http.Server
config *config.HTTPConfig
clients sync.Map // chatID -> *websocket.Conn
mu sync.RWMutex
sessions map[string]*HTTPSession
}
type HTTPSession struct {
ChatID string
Conn *websocket.Conn
Connected time.Time
}
type WSMessage struct {
Type string `json:"type"`
Content string `json:"content"`
ChatID string `json:"chat_id,omitempty"`
}
func NewHTTPChannel(cfg *config.HTTPConfig, messageBus *bus.MessageBus) (*HTTPChannel, error) {
base := NewBaseChannel("http", cfg, messageBus, cfg.AllowFrom)
return &HTTPChannel{
BaseChannel: base,
config: cfg,
sessions: make(map[string]*HTTPSession),
}, nil
}
func (c *HTTPChannel) Start(ctx context.Context) error {
host := c.config.Host
if host == "" {
host = "0.0.0.0"
}
port := c.config.Port
if port == 0 {
port = 8080
}
mux := http.NewServeMux()
mux.HandleFunc("/", c.handleIndex)
mux.HandleFunc("/ws", c.handleWebSocket)
mux.HandleFunc("/api/chat", c.handleChatAPI)
c.server = &http.Server{
Addr: fmt.Sprintf("%s:%d", host, port),
Handler: mux,
}
go func() {
logger.InfoCF("http", "HTTP server starting", map[string]any{
"address": fmt.Sprintf("http://%s:%d", host, port),
})
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.ErrorCF("http", "HTTP server error", map[string]any{
"error": err.Error(),
})
}
}()
c.setRunning(true)
logger.InfoCF("http", "HTTP channel started", map[string]any{
"host": host,
"port": port,
})
return nil
}
func (c *HTTPChannel) Stop(ctx context.Context) error {
logger.InfoC("http", "Stopping HTTP server...")
c.setRunning(false)
if c.server != nil {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := c.server.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("failed to shutdown HTTP server: %w", err)
}
}
return nil
}
func (c *HTTPChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return fmt.Errorf("http channel not running")
}
c.mu.RLock()
session, exists := c.sessions[msg.ChatID]
c.mu.RUnlock()
if !exists {
logger.DebugCF("http", "No active session for chat", map[string]any{
"chat_id": msg.ChatID,
})
return nil
}
wsMsg := WSMessage{
Type: "response",
Content: msg.Content,
ChatID: msg.ChatID,
}
if err := session.Conn.WriteJSON(wsMsg); err != nil {
logger.ErrorCF("http", "Failed to send WebSocket message", map[string]any{
"error": err.Error(),
})
c.removeSession(msg.ChatID)
return err
}
return nil
}
func (c *HTTPChannel) handleIndex(w http.ResponseWriter, r *http.Request) {
tmpl, err := template.ParseFS(httpTemplates, "http_templates/index.html")
if err != nil {
http.Error(w, "Failed to load template", http.StatusInternalServerError)
return
}
data := struct {
Title string
}{
Title: "PicoClaw Web Chat",
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
logger.ErrorCF("http", "Failed to execute template", map[string]any{
"error": err.Error(),
})
}
}
func (c *HTTPChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
logger.ErrorCF("http", "WebSocket upgrade failed", map[string]any{
"error": err.Error(),
})
return
}
chatID := generateChatID(r)
c.mu.Lock()
c.sessions[chatID] = &HTTPSession{
ChatID: chatID,
Conn: conn,
Connected: time.Now(),
}
c.mu.Unlock()
logger.InfoCF("http", "WebSocket client connected", map[string]any{
"chat_id": chatID,
})
defer func() {
c.removeSession(chatID)
conn.Close()
}()
for {
var msg WSMessage
if err := conn.ReadJSON(&msg); err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
logger.ErrorCF("http", "WebSocket read error", map[string]any{
"error": err.Error(),
})
}
break
}
switch msg.Type {
case "message":
c.handleIncomingMessage(chatID, msg.Content)
case "ping":
_ = conn.WriteJSON(WSMessage{Type: "pong"})
}
}
}
func (c *HTTPChannel) handleChatAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Content string `json:"content"`
ChatID string `json:"chat_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.ChatID == "" {
req.ChatID = generateChatID(r)
}
c.handleIncomingMessage(req.ChatID, req.Content)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"chat_id": req.ChatID,
})
}
func (c *HTTPChannel) handleIncomingMessage(chatID, content string) {
senderID := "web_user"
logger.DebugCF("http", "Received message", map[string]any{
"chat_id": chatID,
"content": content,
})
c.HandleMessage(senderID, chatID, content, nil, map[string]string{
"peer_kind": "direct",
"peer_id": chatID,
})
}
func (c *HTTPChannel) removeSession(chatID string) {
c.mu.Lock()
defer c.mu.Unlock()
if session, exists := c.sessions[chatID]; exists {
session.Conn.Close()
delete(c.sessions, chatID)
logger.InfoCF("http", "WebSocket client disconnected", map[string]any{
"chat_id": chatID,
})
}
}
func generateChatID(r *http.Request) string {
return fmt.Sprintf("web_%d", time.Now().UnixNano())
}

View file

@ -0,0 +1,311 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
width: 100%;
max-width: 1200px;
background: #0f0f23;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
overflow: hidden;
display: flex;
flex-direction: column;
height: 90vh;
min-height: 600px;
}
.header {
background: linear-gradient(90deg, #ff6b35 0%, #f7931e 100%);
padding: 20px;
display: flex;
align-items: center;
gap: 12px;
}
.header h1 {
color: white;
font-size: 1.5rem;
font-weight: 600;
}
.header .logo {
font-size: 2rem;
}
.status {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
color: white;
font-size: 0.875rem;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #ef4444;
animation: pulse 2s infinite;
}
.status-dot.connected {
background: #22c55e;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.chat-container {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.message {
max-width: 80%;
padding: 12px 16px;
border-radius: 16px;
line-height: 1.5;
word-wrap: break-word;
}
.message.user {
align-self: flex-end;
background: linear-gradient(135deg, #ff6b35 0%, #f7931e 100%);
color: white;
border-bottom-right-radius: 4px;
}
.message.assistant {
align-self: flex-start;
background: #1e293b;
color: #e2e8f0;
border-bottom-left-radius: 4px;
}
.message.assistant pre {
background: #0f172a;
padding: 12px;
border-radius: 8px;
overflow-x: auto;
margin: 8px 0;
}
.message.assistant code {
font-family: 'Monaco', 'Consolas', monospace;
font-size: 0.875rem;
}
.input-container {
padding: 20px;
background: #1a1a2e;
border-top: 1px solid #2d2d44;
}
.input-wrapper {
display: flex;
gap: 12px;
}
.input-wrapper input {
flex: 1;
padding: 14px 20px;
border: 2px solid #2d2d44;
border-radius: 12px;
background: #0f0f23;
color: white;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.input-wrapper input:focus {
border-color: #ff6b35;
}
.input-wrapper input::placeholder {
color: #64748b;
}
.input-wrapper button {
padding: 14px 28px;
background: linear-gradient(135deg, #ff6b35 0%, #f7931e 100%);
border: none;
border-radius: 12px;
color: white;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.input-wrapper button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(255, 107, 53, 0.4);
}
.input-wrapper button:active {
transform: translateY(0);
}
.input-wrapper button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.typing {
display: flex;
gap: 4px;
padding: 12px 16px;
background: #1e293b;
border-radius: 16px;
align-self: flex-start;
}
.typing span {
width: 8px;
height: 8px;
background: #64748b;
border-radius: 50%;
animation: typing 1.4s infinite;
}
.typing span:nth-child(2) { animation-delay: 0.2s; }
.typing span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<span class="logo">🦞</span>
<h1>PicoClaw</h1>
<div class="status">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Connecting...</span>
</div>
</div>
<div class="chat-container" id="chatContainer">
<div class="message assistant">
👋 ¡Hola! Soy PicoClaw, tu asistente AI. ¿En qué puedo ayudarte?
</div>
</div>
<div class="input-container">
<div class="input-wrapper">
<input type="text" id="messageInput" placeholder="Escribe tu mensaje..." autocomplete="off">
<button id="sendButton">Enviar</button>
</div>
</div>
</div>
<script>
const chatContainer = document.getElementById('chatContainer');
const messageInput = document.getElementById('messageInput');
const sendButton = document.getElementById('sendButton');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
let ws = null;
let connected = false;
function connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
ws.onopen = () => {
connected = true;
statusDot.classList.add('connected');
statusText.textContent = 'Conectado';
};
ws.onclose = () => {
connected = false;
statusDot.classList.remove('connected');
statusText.textContent = 'Desconectado';
setTimeout(connect, 3000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'response') {
removeTypingIndicator();
addMessage(data.content, 'assistant');
} else if (data.type === 'pong') {
// Heartbeat response
}
};
}
function addMessage(content, type) {
const div = document.createElement('div');
div.className = `message ${type}`;
// Simple markdown-like formatting for code blocks
content = content.replace(/```(\w*)\n?([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
content = content.replace(/`([^`]+)`/g, '<code>$1</code>');
content = content.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
content = content.replace(/\n/g, '<br>');
div.innerHTML = content;
chatContainer.appendChild(div);
chatContainer.scrollTop = chatContainer.scrollHeight;
}
function showTypingIndicator() {
const div = document.createElement('div');
div.className = 'typing';
div.id = 'typingIndicator';
div.innerHTML = '<span></span><span></span><span></span>';
chatContainer.appendChild(div);
chatContainer.scrollTop = chatContainer.scrollHeight;
}
function removeTypingIndicator() {
const indicator = document.getElementById('typingIndicator');
if (indicator) indicator.remove();
}
function sendMessage() {
const content = messageInput.value.trim();
if (!content || !connected) return;
addMessage(content, 'user');
messageInput.value = '';
sendButton.disabled = true;
showTypingIndicator();
ws.send(JSON.stringify({
type: 'message',
content: content
}));
setTimeout(() => {
sendButton.disabled = false;
}, 1000);
}
sendButton.addEventListener('click', sendMessage);
messageInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendMessage();
});
// Heartbeat to keep connection alive
setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
connect();
</script>
</body>
</html>

View file

@ -202,6 +202,19 @@ func (m *Manager) initChannels() error {
} }
} }
if m.config.Channels.HTTP.Enabled {
logger.DebugC("channels", "Attempting to initialize HTTP channel")
http, err := NewHTTPChannel(&m.config.Channels.HTTP, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize HTTP channel", map[string]any{
"error": err.Error(),
})
} else {
m.channels["http"] = http
logger.InfoC("channels", "HTTP channel enabled successfully")
}
}
logger.InfoCF("channels", "Channel initialization completed", map[string]any{ logger.InfoCF("channels", "Channel initialization completed", map[string]any{
"enabled_channels": len(m.channels), "enabled_channels": len(m.channels),
}) })

View file

@ -192,6 +192,7 @@ type ChannelsConfig struct {
OneBot OneBotConfig `json:"onebot"` OneBot OneBotConfig `json:"onebot"`
WeCom WeComConfig `json:"wecom"` WeCom WeComConfig `json:"wecom"`
WeComApp WeComAppConfig `json:"wecom_app"` WeComApp WeComAppConfig `json:"wecom_app"`
HTTP HTTPConfig `json:"http"`
} }
type WhatsAppConfig struct { type WhatsAppConfig struct {
@ -296,6 +297,13 @@ type WeComAppConfig struct {
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"` ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
} }
type HTTPConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_HTTP_ENABLED"`
Host string `json:"host" env:"PICOCLAW_CHANNELS_HTTP_HOST"`
Port int `json:"port" env:"PICOCLAW_CHANNELS_HTTP_PORT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_HTTP_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

View file

@ -113,6 +113,12 @@ func DefaultConfig() *Config {
AllowFrom: FlexibleStringSlice{}, AllowFrom: FlexibleStringSlice{},
ReplyTimeout: 5, ReplyTimeout: 5,
}, },
HTTP: HTTPConfig{
Enabled: false,
Host: "0.0.0.0",
Port: 8080,
AllowFrom: FlexibleStringSlice{},
},
}, },
Providers: ProvidersConfig{ Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{WebSearch: true}, OpenAI: OpenAIProviderConfig{WebSearch: true},