vault1984-dashboard/chat.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();"