feat: add HTTP channel with WebSocket support
This commit is contained in:
parent
b9a66248d8
commit
efeedfcaae
5 changed files with 613 additions and 0 deletions
275
pkg/channels/http.go
Normal file
275
pkg/channels/http.go
Normal 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())
|
||||||
|
}
|
||||||
311
pkg/channels/http_templates/index.html
Normal file
311
pkg/channels/http_templates/index.html
Normal 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>
|
||||||
|
|
@ -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),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue