361 lines
16 KiB
Go
361 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
var upgrader = websocket.Upgrader{
|
|
ReadBufferSize: 1024,
|
|
WriteBufferSize: 1024,
|
|
CheckOrigin: func(r *http.Request) bool {
|
|
return true // Allow all origins for now
|
|
},
|
|
}
|
|
|
|
type Message struct {
|
|
ID int64 `json:"id"`
|
|
Sender string `json:"sender"`
|
|
SenderType string `json:"sender_type"` // "ai", "human", "system"
|
|
Content string `json:"content"`
|
|
Timestamp int64 `json:"timestamp"`
|
|
}
|
|
|
|
type Client struct {
|
|
conn *websocket.Conn
|
|
sender string
|
|
senderType string
|
|
}
|
|
|
|
var (
|
|
clients = make(map[*Client]bool)
|
|
clientsMu sync.RWMutex
|
|
messages []Message
|
|
messagesMu sync.RWMutex
|
|
maxMessages = 1000
|
|
)
|
|
|
|
const (
|
|
port = 1985
|
|
tailscaleIP = "100.85.192.60" // Only accessible via Tailscale
|
|
openClawURL = "http://127.0.0.1:18789/tools/invoke"
|
|
openClawToken = "601267edaccf8cd3d6afe222c3ce63602e210ff1ecc9a268"
|
|
)
|
|
|
|
func main() {
|
|
flag.Parse()
|
|
|
|
// HTTP endpoints
|
|
http.HandleFunc("/", handleHTML)
|
|
http.HandleFunc("/chat.js", handleJS)
|
|
http.HandleFunc("/api/send", handleSend)
|
|
http.HandleFunc("/api/messages", handleMessages)
|
|
http.HandleFunc("/api/status", handleStatus)
|
|
|
|
// WebSocket
|
|
http.HandleFunc("/ws", handleWS)
|
|
|
|
addr := fmt.Sprintf("100.85.192.60:%d", port) // Bind to Tailscale IP only
|
|
fmt.Printf("Vault1984 Chat v0.2 (Go+WS) running on http://localhost:%d\n", port)
|
|
log.Fatal(http.ListenAndServe(addr, nil))
|
|
}
|
|
|
|
func handleHTML(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Write([]byte(htmlPage))
|
|
}
|
|
|
|
func handleJS(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/javascript")
|
|
w.Write([]byte(jsPage))
|
|
}
|
|
|
|
func handleSend(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
http.Error(w, "Method not allowed", 405)
|
|
return
|
|
}
|
|
|
|
var msg Message
|
|
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
|
http.Error(w, "Invalid JSON", 400)
|
|
return
|
|
}
|
|
|
|
if msg.Sender == "" || msg.Content == "" {
|
|
http.Error(w, "Missing sender or content", 400)
|
|
return
|
|
}
|
|
|
|
if msg.SenderType == "" {
|
|
msg.SenderType = "ai"
|
|
}
|
|
|
|
msg.Timestamp = time.Now().UnixMilli()
|
|
msg.ID = msg.Timestamp
|
|
|
|
messagesMu.Lock()
|
|
messages = append([]Message{msg}, messages...)
|
|
if len(messages) > maxMessages {
|
|
messages = messages[:maxMessages]
|
|
}
|
|
messagesMu.Unlock()
|
|
|
|
// Broadcast to all connected WS clients
|
|
broadcast(msg)
|
|
|
|
// If it's a human message, respond via OpenClaw agent
|
|
if msg.SenderType == "human" {
|
|
log.Printf("Calling OpenClaw for message from %s: %s", msg.Sender, msg.Content)
|
|
|
|
// Call OpenClaw to get real response (synchronous - wait for it)
|
|
response := callOpenClaw(msg.Content, msg.Sender)
|
|
|
|
// Add the response to messages
|
|
responseMsg := Message{
|
|
ID: time.Now().UnixMilli(),
|
|
Sender: "Hans",
|
|
SenderType: "ai",
|
|
Content: response,
|
|
Timestamp: time.Now().UnixMilli(),
|
|
}
|
|
messagesMu.Lock()
|
|
messages = append([]Message{responseMsg}, messages...)
|
|
if len(messages) > maxMessages {
|
|
messages = messages[:maxMessages]
|
|
}
|
|
messagesMu.Unlock()
|
|
broadcast(responseMsg)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"success": true,
|
|
"message": msg,
|
|
})
|
|
}
|
|
|
|
func handleMessages(w http.ResponseWriter, r *http.Request) {
|
|
since := 0
|
|
if s := r.URL.Query().Get("since"); s != "" {
|
|
fmt.Sscanf(s, "%d", &since)
|
|
}
|
|
|
|
messagesMu.RLock()
|
|
var filtered []Message
|
|
for _, m := range messages {
|
|
if int64(since) < m.Timestamp {
|
|
filtered = append(filtered, m)
|
|
}
|
|
}
|
|
messagesMu.RUnlock()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(filtered)
|
|
}
|
|
|
|
func handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
clientsMu.RLock()
|
|
defer clientsMu.RUnlock()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"status": "online",
|
|
"messages_count": len(messages),
|
|
"connected_count": len(clients),
|
|
"uptime": "N/A", // Could track startup time
|
|
})
|
|
}
|
|
|
|
func handleWS(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
log.Println("WS upgrade error:", err)
|
|
return
|
|
}
|
|
|
|
client := &Client{conn: conn, sender: "unknown", senderType: "human"}
|
|
clientsMu.Lock()
|
|
clients[client] = true
|
|
clientsMu.Unlock()
|
|
|
|
// Send recent messages to new client (last 20)
|
|
messagesMu.RLock()
|
|
recent := messages
|
|
if len(recent) > 20 {
|
|
recent = recent[:20]
|
|
}
|
|
messagesMu.RUnlock()
|
|
|
|
for _, m := range recent {
|
|
client.sendJSON(map[string]interface{}{
|
|
"type": "history",
|
|
"messages": []Message{m},
|
|
})
|
|
}
|
|
|
|
// Handle incoming messages
|
|
for {
|
|
_, data, err := conn.ReadMessage()
|
|
if err != nil {
|
|
break
|
|
}
|
|
|
|
var msg map[string]interface{}
|
|
if err := json.Unmarshal(data, &msg); err != nil {
|
|
continue
|
|
}
|
|
|
|
if msg["type"] == "register" {
|
|
if s, ok := msg["sender"].(string); ok {
|
|
client.sender = s
|
|
}
|
|
if st, ok := msg["sender_type"].(string); ok {
|
|
client.senderType = st
|
|
}
|
|
client.sendJSON(map[string]string{"type": "registered", "sender": client.sender})
|
|
}
|
|
|
|
if msg["type"] == "ping" {
|
|
client.sendJSON(map[string]string{"type": "pong"})
|
|
}
|
|
|
|
// Handle message sent via WS
|
|
log.Printf("WS message received: %v", msg)
|
|
if content, ok := msg["content"].(string); ok && content != "" {
|
|
sender := client.sender
|
|
senderType := client.senderType
|
|
if s, ok := msg["sender"].(string); ok && s != "" {
|
|
sender = s
|
|
}
|
|
if st, ok := msg["sender_type"].(string); ok && st != "" {
|
|
senderType = st
|
|
}
|
|
|
|
log.Printf("Saving message from %s: %s", sender, content)
|
|
|
|
// Save and broadcast the user's message
|
|
msgObj := Message{
|
|
ID: time.Now().UnixMilli(),
|
|
Sender: sender,
|
|
SenderType: senderType,
|
|
Content: content,
|
|
Timestamp: time.Now().UnixMilli(),
|
|
}
|
|
|
|
messagesMu.Lock()
|
|
messages = append([]Message{msgObj}, messages...)
|
|
if len(messages) > maxMessages {
|
|
messages = messages[:maxMessages]
|
|
}
|
|
messagesMu.Unlock()
|
|
|
|
broadcast(msgObj)
|
|
|
|
// If it's a human message, call OpenClaw and broadcast response
|
|
if senderType == "human" {
|
|
go func() {
|
|
response := callOpenClaw(content, sender)
|
|
responseMsg := Message{
|
|
ID: time.Now().UnixMilli(),
|
|
Sender: "Hans",
|
|
SenderType: "ai",
|
|
Content: response,
|
|
Timestamp: time.Now().UnixMilli(),
|
|
}
|
|
messagesMu.Lock()
|
|
messages = append([]Message{responseMsg}, messages...)
|
|
if len(messages) > maxMessages {
|
|
messages = messages[:maxMessages]
|
|
}
|
|
messagesMu.Unlock()
|
|
broadcast(responseMsg)
|
|
}()
|
|
}
|
|
}
|
|
}
|
|
|
|
clientsMu.Lock()
|
|
delete(clients, client)
|
|
clientsMu.Unlock()
|
|
conn.Close()
|
|
}
|
|
|
|
func (c *Client) sendJSON(v interface{}) {
|
|
if err := c.conn.WriteJSON(v); err != nil {
|
|
log.Println("WS send error:", err)
|
|
}
|
|
}
|
|
|
|
func broadcast(msg Message) {
|
|
clientsMu.RLock()
|
|
defer clientsMu.RUnlock()
|
|
|
|
for client := range clients {
|
|
client.sendJSON(map[string]interface{}{
|
|
"type": "message",
|
|
"id": msg.ID,
|
|
"sender": msg.Sender,
|
|
"sender_type": msg.SenderType,
|
|
"content": msg.Content,
|
|
"timestamp": msg.Timestamp,
|
|
})
|
|
}
|
|
}
|
|
|
|
// callOpenClaw sends a message to OpenClaw and returns a real response
|
|
func callOpenClaw(content, sender string) string {
|
|
// Use local agent - will load model (~10s) but works
|
|
// This is the fastest we can get with CLI without Gateway integration
|
|
cmd := exec.Command("openclaw", "agent",
|
|
"--message", content,
|
|
"--session-id", fmt.Sprintf("chat-%d", time.Now().UnixMilli()),
|
|
"--local",
|
|
"--timeout", "15")
|
|
|
|
output, err := cmd.Output()
|
|
|
|
if err != nil {
|
|
log.Printf("OpenClaw agent error: %v, output: %s", err, string(output))
|
|
return "📩 Received: " + content
|
|
}
|
|
|
|
// Extract the response from the output
|
|
response := strings.TrimSpace(string(output))
|
|
|
|
// Clean up the response - take the first meaningful line(s)
|
|
lines := strings.Split(response, "\n")
|
|
var cleanResponse string
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line != "" && !strings.HasPrefix(line, "{") && !strings.HasPrefix(line, "[") {
|
|
cleanResponse = line
|
|
break
|
|
}
|
|
}
|
|
|
|
if cleanResponse == "" {
|
|
cleanResponse = "📩 Received: " + content
|
|
}
|
|
|
|
// Truncate if too long
|
|
if len(cleanResponse) > 500 {
|
|
cleanResponse = cleanResponse[:500] + "..."
|
|
}
|
|
|
|
log.Printf("Agent response: %s", cleanResponse)
|
|
return cleanResponse
|
|
}
|
|
|
|
const htmlPage = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Vault1984 — AI Chat</title>\n <style>\n * { box-sizing: border-box; margin: 0; padding: 0; }\n body { \n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n background: #050505; \n color: #e0e0e0; \n min-height: 100vh;\n display: flex;\n justify-content: center;\n }\n .container {\n width: 100%;\n max-width: 600px;\n display: flex;\n flex-direction: column;\n border-left: 1px solid #1a1a1a;\n border-right: 1px solid #1a1a1a;\n }\n header {\n padding: 20px;\n border-bottom: 1px solid #1a1a1a;\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n h1 { font-size: 18px; color: #00ff88; letter-spacing: 2px; }\n .status { font-size: 12px; color: #666; }\n .status.online { color: #00ff88; }\n #chat { \n flex: 1; \n overflow-y: auto; \n padding: 20px;\n display: flex;\n flex-direction: column;\n gap: 16px;\n }\n .message {\n max-width: 90%;\n padding: 12px 16px;\n border-radius: 8px;\n animation: fadeIn 0.2s ease;\n }\n @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }\n /* Color-coded by sender */\n .message.hans { \n align-self: flex-start; \n background: #1a2a1a; \n border-left: 3px solid #00ff88;\n }\n .message.james { \n align-self: flex-start; \n background: #1a1a2a; \n border-left: 3px solid #6366f1;\n }\n .message.johan { \n align-self: flex-end; \n background: #2a1a1a; \n border-right: 3px solid #ff6b6b;\n }\n .message.human { \n align-self: flex-end; \n background: #2a1a1a; \n border-right: 3px solid #ff6b6b;\n }\n .message.ai { \n align-self: flex-start; \n background: #1a1a2a; \n border-left: 3px solid #6366f1;\n }\n .message.system { \n align-self: center; \n background: transparent; \n color: #666;\n font-size: 12px;\n border: none;\n text-align: center;\n }\n .sender { font-size: 11px; color: #888; margin-bottom: 4px; }\n .sender.hans { color: #00ff88; }\n .sender.james { color: #6366f1; }\n .sender.johan, .sender.human { color: #ff6b6b; }\n .content { font-size: 14px; line-height: 1.5; white-space: pre-wrap; word-wrap: break-word; }\n .content code { background: #222; padding: 2px 6px; border-radius: 4px; }\n .content pre { background: #111; padding: 10px; border-radius: 8px; overflow-x: auto; }\n .time { font-size: 10px; color: #444; margin-top: 4px; }\n #input-area {\n padding: 20px;\n border-top: 1px solid #1a1a1a;\n display: flex;\n gap: 12px;\n }\n input { \n flex: 1;\n background: #0d0d0d;\n border: 1px solid #333;\n color: #e0e0e0;\n padding: 12px 16px;\n font-family: inherit;\n font-size: 14px;\n border-radius: 8px;\n }\n input:focus { outline: none; border-color: #00ff88; }\n button {\n background: #00ff88;\n color: #0a0a0a;\n border: none;\n padding: 12px 24px;\n font-family: inherit;\n font-weight: bold;\n border-radius: 8px;\n cursor: pointer;\n transition: all 0.2s;\n }\n button:hover { transform: scale(1.02); box-shadow: 0 0 20px rgba(0,255,136,0.3); }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <header>\n <h1>🔒 VAULT1984 // CHAT v0.3</h1>\n <div class=\"status online\" id=\"status\">● CONNECTED</div>\n </header>\n <div id=\"chat\"></div>\n <div id=\"input-area\">\n <input type=\"text\" id=\"msg\" placeholder=\"Type a message...\" autofocus>\n <button onclick=\"send()\">SEND</button>\n </div>\n </div>\n <script src=\"/chat.js\"></script>\n</body>\n</html>"
|
|
|
|
const jsPage = "let ws;\nlet reconnectAttempts = 0;\nconst maxReconnectAttempts = 10;\nconst reconnectDelay = 1000;\n\nfunction getSenderClass(sender, senderType) {\n if (!sender) return 'system';\n const s = sender.toLowerCase();\n if (s === 'johan') return 'johan';\n if (s === 'hans') return 'hans';\n if (s === 'james') return 'james';\n if (senderType === 'human') return 'human';\n return 'ai';\n}\n\nfunction connect() {\n ws = new WebSocket('ws://' + location.host + '/ws');\n \n ws.onopen = () => {\n document.getElementById('status').className = 'status online';\n document.getElementById('status').textContent = '● CONNECTED';\n reconnectAttempts = 0;\n ws.send(JSON.stringify({ type: 'register', sender: 'Johan', sender_type: 'human' }));\n };\n \n ws.onmessage = (e) => {\n const data = JSON.parse(e.data);\n if (data.type === 'history' && data.messages) {\n // Sort by timestamp (oldest first)\n data.messages.sort((a, b) => a.timestamp - b.timestamp);\n data.messages.forEach(addMessage);\n } else if (data.type === 'message') {\n addMessage(data);\n }\n };\n \n ws.onclose = () => {\n document.getElementById('status').className = 'status';\n document.getElementById('status').textContent = '○ DISCONNECTED';\n if (reconnectAttempts < maxReconnectAttempts) {\n reconnectAttempts++;\n setTimeout(connect, reconnectDelay * reconnectAttempts);\n }\n };\n}\n\nfunction addMessage(msg) {\n const div = document.createElement('div');\n const senderClass = getSenderClass(msg.sender, msg.sender_type);\n div.className = 'message ' + senderClass;\n \n let content = msg.content || '';\n content = content.replace(/`([^`]+)`/g, '<code>$1</code>');\n content = content.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');\n \n div.innerHTML = '<div class=\"sender ' + senderClass + '\">' + (msg.sender || 'System') + '</div><div class=\"content\">' + content + '</div><div class=\"time\">' + new Date(msg.timestamp).toLocaleString() + '</div>';\n document.getElementById('chat').appendChild(div);\n document.getElementById('chat').scrollTop = document.getElementById('chat').scrollHeight;\n}\n\nfunction send() {\n const input = document.getElementById('msg');\n const content = input.value.trim();\n if (!content || !ws || ws.readyState !== 1) return;\n ws.send(JSON.stringify({ sender: 'Johan', sender_type: 'human', content: content }));\n input.value = '';\n}\n\ndocument.getElementById('msg').addEventListener('keypress', (e) => { if (e.key === 'Enter') send(); });\nsetInterval(() => { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'ping' })); }, 30000);\nconnect();" |