agentchat: group chat for humans and AI agents
Go server with WebSocket UI, OpenClaw gateway integration, persistent sessions, name-based routing, and cross-agent forwarding. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
a0cc49f4c9
|
|
@ -0,0 +1,15 @@
|
|||
[Unit]
|
||||
Description=agentchat
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=johan
|
||||
WorkingDirectory=/home/johan/dev/agentchat
|
||||
ExecStart=/home/johan/dev/agentchat/agentchat
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
Environment=PORT=7777
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OpenClaw HTTP API client — uses the OpenAI-compatible /v1/chat/completions endpoint
|
||||
|
||||
type GatewayConfig struct {
|
||||
URL string // http://host:port
|
||||
Token string
|
||||
}
|
||||
|
||||
type GatewayPool struct {
|
||||
mu sync.Mutex
|
||||
gateways map[string]*GatewayConfig
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewGatewayPool() *GatewayPool {
|
||||
return &GatewayPool{
|
||||
gateways: make(map[string]*GatewayConfig),
|
||||
client: &http.Client{Timeout: 120 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (gp *GatewayPool) Register(host string, cfg *GatewayConfig) {
|
||||
gp.mu.Lock()
|
||||
defer gp.mu.Unlock()
|
||||
gp.gateways[host] = cfg
|
||||
}
|
||||
|
||||
func (gp *GatewayPool) Get(host string) *GatewayConfig {
|
||||
gp.mu.Lock()
|
||||
defer gp.mu.Unlock()
|
||||
return gp.gateways[host]
|
||||
}
|
||||
|
||||
func (gp *GatewayPool) CallAgent(host, agentID, message, session string) (string, error) {
|
||||
cfg := gp.Get(host)
|
||||
if cfg == nil {
|
||||
return "", fmt.Errorf("no gateway configured for host %s", host)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"model": "openclaw:" + agentID,
|
||||
"user": session,
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": message},
|
||||
},
|
||||
})
|
||||
|
||||
req, err := http.NewRequest("POST", cfg.URL+"/v1/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.Token)
|
||||
|
||||
resp, err := gp.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("http: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("http %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return "", fmt.Errorf("parse: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Choices) == 0 || result.Choices[0].Message.Content == "" {
|
||||
return "[completed, no text reply]", nil
|
||||
}
|
||||
|
||||
return result.Choices[0].Message.Content, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
module agentchat
|
||||
|
||||
go 1.23.6
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>agentchat</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--surface: #141414;
|
||||
--border: #222;
|
||||
--text: #e0e0e0;
|
||||
--muted: #666;
|
||||
--accent: #4a9eff;
|
||||
--accent2: #7c5cff;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
}
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
padding-top: max(12px, env(safe-area-inset-top));
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
header h1 { font-size: 17px; font-weight: 600; letter-spacing: -0.3px; }
|
||||
header .dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #4caf50; margin-right: 8px; display: inline-block;
|
||||
}
|
||||
#status { font-size: 12px; color: var(--muted); }
|
||||
|
||||
#messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px 16px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.msg {
|
||||
margin-bottom: 8px;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } }
|
||||
.msg .meta { font-size: 11px; color: var(--muted); margin-bottom: 2px; }
|
||||
.msg .meta .name { font-weight: 600; margin-right: 6px; }
|
||||
.msg .meta .name.human { color: #6fcf97; }
|
||||
.msg .meta .name.agent { color: #b794f6; }
|
||||
.msg .meta .name.system { color: #888; }
|
||||
.msg .bubble {
|
||||
display: inline-block; max-width: 100%;
|
||||
padding: 8px 12px; border-radius: 14px;
|
||||
font-size: 15px; line-height: 1.4;
|
||||
word-wrap: break-word; white-space: pre-wrap;
|
||||
}
|
||||
.msg.self .bubble {
|
||||
background: var(--accent); color: #fff;
|
||||
border-bottom-right-radius: 4px; float: right;
|
||||
}
|
||||
.msg.self { text-align: right; }
|
||||
.msg.self::after { content: ''; display: block; clear: both; }
|
||||
.msg.other .bubble {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.msg.agent-msg .bubble {
|
||||
background: #1a1530; border: 1px solid #2d2450;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.msg.system-msg .bubble {
|
||||
background: #1a1a1a; font-style: italic;
|
||||
font-size: 13px; color: var(--muted);
|
||||
}
|
||||
.msg.status-msg .bubble {
|
||||
background: transparent; border: 1px solid #333;
|
||||
font-size: 13px; color: #8a8;
|
||||
border-radius: 8px; padding: 4px 10px;
|
||||
}
|
||||
|
||||
/* Thinking indicator */
|
||||
#thinking-bar {
|
||||
padding: 0 16px;
|
||||
font-size: 12px;
|
||||
color: var(--accent2);
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
transition: height 0.2s, padding 0.2s;
|
||||
}
|
||||
#thinking-bar.active {
|
||||
height: 22px;
|
||||
padding: 3px 16px;
|
||||
}
|
||||
@keyframes pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } }
|
||||
#thinking-bar span { animation: pulse 1.5s infinite; }
|
||||
|
||||
/* Input area */
|
||||
#input-area {
|
||||
display: flex; flex-direction: column; gap: 0;
|
||||
padding: 8px 12px;
|
||||
padding-bottom: max(8px, env(safe-area-inset-bottom));
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#agent-bar {
|
||||
display: flex; gap: 6px;
|
||||
padding: 4px 0 8px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
#agent-bar::-webkit-scrollbar { display: none; }
|
||||
.agent-btn {
|
||||
flex-shrink: 0; padding: 4px 12px;
|
||||
border-radius: 16px; border: 1px solid var(--border);
|
||||
background: transparent; color: var(--muted);
|
||||
font-size: 13px; cursor: pointer; transition: all 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
.agent-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||
.agent-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.agent-btn .thinking-dot {
|
||||
display: none;
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--accent2);
|
||||
position: absolute; top: -2px; right: -2px;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
.agent-btn.is-thinking .thinking-dot { display: block; }
|
||||
#compose { display: flex; gap: 8px; align-items: flex-end; }
|
||||
#msg-input {
|
||||
flex: 1; background: var(--bg);
|
||||
border: 1px solid var(--border); border-radius: 20px;
|
||||
padding: 10px 16px; color: var(--text);
|
||||
font-size: 15px; outline: none; resize: none;
|
||||
max-height: 120px; line-height: 1.4;
|
||||
}
|
||||
#msg-input::placeholder { color: var(--muted); }
|
||||
#msg-input:focus { border-color: var(--accent); }
|
||||
#send-btn {
|
||||
width: 40px; height: 40px; border-radius: 50%;
|
||||
border: none; background: var(--accent); color: #fff;
|
||||
font-size: 18px; cursor: pointer; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
#send-btn:active { opacity: 0.7; }
|
||||
|
||||
#login {
|
||||
position: fixed; inset: 0; background: var(--bg);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
#login.hidden { display: none; }
|
||||
#login-box { text-align: center; padding: 32px; }
|
||||
#login-box h2 { font-size: 24px; margin-bottom: 24px; font-weight: 600; }
|
||||
#login-box input {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 12px 20px; color: var(--text);
|
||||
font-size: 17px; width: 260px; text-align: center; outline: none;
|
||||
}
|
||||
#login-box input:focus { border-color: var(--accent); }
|
||||
#login-box button {
|
||||
display: block; width: 260px; margin-top: 12px;
|
||||
padding: 12px; border-radius: 12px; border: none;
|
||||
background: var(--accent); color: #fff;
|
||||
font-size: 16px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="login">
|
||||
<div id="login-box">
|
||||
<h2>agentchat</h2>
|
||||
<input type="text" id="name-input" placeholder="Your name" autocomplete="off" autofocus>
|
||||
<button onclick="doLogin()">Enter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app">
|
||||
<header>
|
||||
<div><span class="dot"></span><h1 style="display:inline">agentchat</h1></div>
|
||||
<span id="status">connecting...</span>
|
||||
</header>
|
||||
<div id="messages"></div>
|
||||
<div id="thinking-bar"><span></span></div>
|
||||
<div id="input-area">
|
||||
<div id="agent-bar">
|
||||
<button class="agent-btn active" data-to="" onclick="selectAgent(this)">All</button>
|
||||
</div>
|
||||
<div id="compose">
|
||||
<textarea id="msg-input" rows="1" placeholder="Message..." enterkeyhint="send"></textarea>
|
||||
<button id="send-btn" onclick="send()">▶</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const AGENTS = {james: 'James', mira: 'Mira', hans: 'Hans'};
|
||||
const thinkingAgents = new Set();
|
||||
let ws, username, selectedAgent = '';
|
||||
|
||||
const bar = document.getElementById('agent-bar');
|
||||
for (const [id, name] of Object.entries(AGENTS)) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'agent-btn';
|
||||
btn.dataset.to = id;
|
||||
btn.innerHTML = `${name}<span class="thinking-dot"></span>`;
|
||||
btn.onclick = function() { selectAgent(this); };
|
||||
bar.appendChild(btn);
|
||||
}
|
||||
|
||||
function selectAgent(btn) {
|
||||
document.querySelectorAll('.agent-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
selectedAgent = btn.dataset.to;
|
||||
document.getElementById('msg-input').placeholder =
|
||||
selectedAgent ? `Message ${AGENTS[selectedAgent]}...` : 'Message...';
|
||||
document.getElementById('msg-input').focus();
|
||||
}
|
||||
|
||||
function updateThinkingBar() {
|
||||
const bar = document.getElementById('thinking-bar');
|
||||
if (thinkingAgents.size === 0) {
|
||||
bar.classList.remove('active');
|
||||
bar.querySelector('span').textContent = '';
|
||||
} else {
|
||||
const names = [...thinkingAgents].join(', ');
|
||||
bar.classList.add('active');
|
||||
bar.querySelector('span').textContent = `${names} thinking...`;
|
||||
}
|
||||
// Update button dots
|
||||
document.querySelectorAll('.agent-btn[data-to]').forEach(btn => {
|
||||
const agentName = AGENTS[btn.dataset.to];
|
||||
btn.classList.toggle('is-thinking', agentName && thinkingAgents.has(agentName));
|
||||
});
|
||||
}
|
||||
|
||||
function doLogin() {
|
||||
const name = document.getElementById('name-input').value.trim();
|
||||
if (!name) return;
|
||||
username = name;
|
||||
localStorage.setItem('agentchat-user', name);
|
||||
document.getElementById('login').classList.add('hidden');
|
||||
connect();
|
||||
}
|
||||
|
||||
const saved = localStorage.getItem('agentchat-user');
|
||||
if (saved) {
|
||||
username = saved;
|
||||
document.getElementById('login').classList.add('hidden');
|
||||
connect();
|
||||
}
|
||||
|
||||
document.getElementById('name-input').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') doLogin();
|
||||
});
|
||||
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(`${proto}//${location.host}/ws?user=${encodeURIComponent(username)}`);
|
||||
ws.onopen = () => {
|
||||
document.getElementById('status').textContent = 'connected';
|
||||
document.querySelector('.dot').style.background = '#4caf50';
|
||||
};
|
||||
ws.onclose = () => {
|
||||
document.getElementById('status').textContent = 'reconnecting...';
|
||||
document.querySelector('.dot').style.background = '#ff5252';
|
||||
setTimeout(connect, 2000);
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
handleMessage(msg);
|
||||
};
|
||||
}
|
||||
|
||||
function handleMessage(msg) {
|
||||
if (msg.kind === 'thinking') {
|
||||
thinkingAgents.add(msg.user);
|
||||
updateThinkingBar();
|
||||
return;
|
||||
}
|
||||
if (msg.kind === 'thinking-done') {
|
||||
thinkingAgents.delete(msg.user);
|
||||
updateThinkingBar();
|
||||
return;
|
||||
}
|
||||
|
||||
// Agent replied — clear thinking
|
||||
if (Object.values(AGENTS).includes(msg.user)) {
|
||||
thinkingAgents.delete(msg.user);
|
||||
updateThinkingBar();
|
||||
}
|
||||
|
||||
appendMessage(msg);
|
||||
}
|
||||
|
||||
function appendMessage(msg) {
|
||||
const el = document.createElement('div');
|
||||
const isSelf = msg.user === username;
|
||||
const isAgent = Object.values(AGENTS).includes(msg.user);
|
||||
const isSystem = msg.user === 'system';
|
||||
const isStatus = msg.kind === 'status';
|
||||
|
||||
el.className = 'msg';
|
||||
if (isStatus) el.classList.add('status-msg');
|
||||
else if (isSelf) el.classList.add('self');
|
||||
else if (isAgent) el.classList.add('agent-msg');
|
||||
else if (isSystem) el.classList.add('system-msg');
|
||||
else el.classList.add('other');
|
||||
|
||||
const nameClass = isAgent ? 'agent' : isSystem ? 'system' : 'human';
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="meta">
|
||||
<span class="name ${nameClass}">${esc(msg.user)}</span>
|
||||
<span>${esc(msg.timestamp)}</span>
|
||||
</div>
|
||||
<div class="bubble">${esc(msg.text)}</div>
|
||||
`;
|
||||
|
||||
const cont = document.getElementById('messages');
|
||||
const atBottom = cont.scrollTop + cont.clientHeight >= cont.scrollHeight - 60;
|
||||
cont.appendChild(el);
|
||||
if (atBottom) cont.scrollTop = cont.scrollHeight;
|
||||
}
|
||||
|
||||
function send() {
|
||||
const input = document.getElementById('msg-input');
|
||||
const text = input.value.trim();
|
||||
if (!text || !ws || ws.readyState !== 1) return;
|
||||
ws.send(JSON.stringify({ text, to: selectedAgent }));
|
||||
input.value = '';
|
||||
input.style.height = 'auto';
|
||||
input.focus();
|
||||
}
|
||||
|
||||
const input = document.getElementById('msg-input');
|
||||
input.addEventListener('input', () => {
|
||||
input.style.height = 'auto';
|
||||
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
|
||||
});
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,527 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
User string `json:"user"`
|
||||
Text string `json:"text"`
|
||||
To string `json:"to,omitempty"` // target agent or empty for broadcast
|
||||
Kind string `json:"kind,omitempty"` // chat (default), status, task, system, thinking
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*websocket.Conn]string // conn -> username
|
||||
logFile *os.File
|
||||
pad sync.Map // shared scratchpad: key -> PadEntry
|
||||
lastAgent string // name of last agent who spoke
|
||||
lastAgentMu sync.Mutex
|
||||
}
|
||||
|
||||
type PadEntry struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Author string `json:"author"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
var agents = map[string]AgentConfig{
|
||||
"james": {Name: "James", Agent: "main", Host: "forge"},
|
||||
"mira": {Name: "Mira", Agent: "mira", Host: "forge"},
|
||||
"hans": {Name: "Hans", Agent: "main", Host: "vault1984-hq"},
|
||||
}
|
||||
|
||||
type AgentConfig struct {
|
||||
Name string
|
||||
Agent string
|
||||
Host string // "local" or tailscale hostname
|
||||
}
|
||||
|
||||
var gatewayPool = NewGatewayPool()
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
f, err := os.OpenFile("chat.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return &Hub{
|
||||
clients: make(map[*websocket.Conn]string),
|
||||
logFile: f,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) broadcast(msg Message) {
|
||||
data, _ := json.Marshal(msg)
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for conn := range h.clients {
|
||||
conn.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) logMessage(msg Message) {
|
||||
line := fmt.Sprintf("[%s] [%s] %s\n", msg.Timestamp, msg.User, msg.Text)
|
||||
h.logFile.WriteString(line)
|
||||
}
|
||||
|
||||
// sendToAgent dispatches a message to an agent. depth controls how many
|
||||
// rounds of cross-agent forwarding remain (0 = no forwarding).
|
||||
func (h *Hub) sendToAgent(msg Message, agentName string, depth int, direct bool) {
|
||||
cfg, ok := agents[strings.ToLower(agentName)]
|
||||
if !ok {
|
||||
h.broadcast(Message{
|
||||
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
|
||||
User: "system",
|
||||
Text: fmt.Sprintf("Unknown agent: %s", agentName),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
// Only show thinking indicator for direct (human-initiated) messages
|
||||
if direct {
|
||||
h.broadcast(Message{
|
||||
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
|
||||
User: cfg.Name,
|
||||
Kind: "thinking",
|
||||
})
|
||||
}
|
||||
|
||||
reply, err := callAgent(cfg, msg.Text, msg.User, direct)
|
||||
if err != nil {
|
||||
reply = fmt.Sprintf("[error: %v]", err)
|
||||
}
|
||||
trimmed := strings.TrimSpace(reply)
|
||||
if trimmed == "_skip" || trimmed == "" {
|
||||
if direct {
|
||||
h.broadcast(Message{
|
||||
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
|
||||
User: cfg.Name,
|
||||
Kind: "thinking-done",
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
resp := Message{
|
||||
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
|
||||
User: cfg.Name,
|
||||
Text: reply,
|
||||
}
|
||||
h.logMessage(resp)
|
||||
h.broadcast(resp)
|
||||
|
||||
h.lastAgentMu.Lock()
|
||||
h.lastAgent = strings.ToLower(agentName)
|
||||
h.lastAgentMu.Unlock()
|
||||
|
||||
// Ping Johan if he's mentioned
|
||||
if strings.Contains(strings.ToLower(reply), "johan") {
|
||||
go notifyJohan(cfg.Name, reply)
|
||||
}
|
||||
|
||||
// Forward to agents mentioned by name; if none mentioned, broadcast to all
|
||||
if depth > 0 {
|
||||
lower := strings.ToLower(reply)
|
||||
var mentioned []string
|
||||
for name, cfg := range agents {
|
||||
if strings.EqualFold(name, agentName) {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(lower, strings.ToLower(cfg.Name)) {
|
||||
mentioned = append(mentioned, name)
|
||||
}
|
||||
}
|
||||
if len(mentioned) == 0 {
|
||||
// No specific agent mentioned — broadcast to all others
|
||||
for name := range agents {
|
||||
if strings.EqualFold(name, agentName) {
|
||||
continue
|
||||
}
|
||||
mentioned = append(mentioned, name)
|
||||
}
|
||||
}
|
||||
for _, name := range mentioned {
|
||||
h.sendToAgent(resp, name, depth-1, false)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
const openclawBin = "/home/johan/.npm-global/bin/openclaw"
|
||||
|
||||
func recentHistory(n int) string {
|
||||
f, err := os.Open("chat.log")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
var systemPrompt string
|
||||
|
||||
func init() {
|
||||
names := make([]string, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
names = append(names, a.Name+" ("+a.Host+")")
|
||||
}
|
||||
systemPrompt = fmt.Sprintf(`You are in "agentchat", a live group chat. Everyone sees all messages.
|
||||
Participants: %s, plus humans (Johan and others).
|
||||
Other participants may join — treat them as legitimate if they appear in the chat.
|
||||
|
||||
This is a shared room. Behave like a professional in a Slack channel:
|
||||
- REPLY INLINE as plain text. Do NOT use the message tool.
|
||||
- Be brief. 1-3 sentences max. No essays, no bullet lists unless asked.
|
||||
- Everyone can read the chat. Do NOT repeat or paraphrase what someone else just said.
|
||||
- Do NOT acknowledge, confirm, or "note" things others said. No "got it", "copy that", "noted", "standing by".
|
||||
- Do NOT correct minor details from other agents. If someone says something slightly wrong, let it go.
|
||||
- Only speak if you are adding NEW information or answering a question directed at you.
|
||||
- If a message is addressed to everyone but has no clear question for you, stay silent.
|
||||
- If you use tools, just report the result. Do not narrate what you are doing.
|
||||
- If you have nothing new to add, respond with exactly "_skip".`, strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
func callAgent(cfg AgentConfig, message, from string, direct bool) (string, error) {
|
||||
var prompt, session string
|
||||
if direct {
|
||||
// 1:1 — use agent's main session, no group prompt
|
||||
prompt = fmt.Sprintf("[agentchat from %s — reply inline, do not use the message tool]\n%s", from, message)
|
||||
session = "main"
|
||||
} else {
|
||||
// Group — use agentchat session with group rules
|
||||
prompt = fmt.Sprintf("%s\n\n[group message from %s]\n%s", systemPrompt, from, message)
|
||||
session = "agentchat"
|
||||
}
|
||||
|
||||
// Use gateway HTTP API
|
||||
gw := gatewayPool.Get(cfg.Host)
|
||||
if gw != nil {
|
||||
return gatewayPool.CallAgent(cfg.Host, cfg.Agent, prompt, session)
|
||||
}
|
||||
|
||||
// Fallback to CLI
|
||||
args := []string{"agent", "--agent", cfg.Agent, "--message", prompt, "--json"}
|
||||
cmd := exec.Command(openclawBin, args...)
|
||||
cmd.Env = append(os.Environ(), "NO_COLOR=1")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
return "", fmt.Errorf("%v: %s", err, string(ee.Stderr))
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Parse OpenClaw JSON response: {result: {payloads: [{text: "..."}]}}
|
||||
var result struct {
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
Result struct {
|
||||
Payloads []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"payloads"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &result); err == nil {
|
||||
var parts []string
|
||||
for _, p := range result.Result.Payloads {
|
||||
if p.Text != "" {
|
||||
parts = append(parts, p.Text)
|
||||
}
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
return strings.Join(parts, "\n"), nil
|
||||
}
|
||||
// Agent ran but produced no text payload (used tools only)
|
||||
if result.Status == "ok" {
|
||||
return fmt.Sprintf("[completed — %s, no text reply]", result.Summary), nil
|
||||
}
|
||||
return fmt.Sprintf("[%s: %s]", result.Status, result.Summary), nil
|
||||
}
|
||||
|
||||
// Fallback: return first line only (avoid dumping huge JSON)
|
||||
first := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0]
|
||||
if len(first) > 200 {
|
||||
first = first[:200] + "..."
|
||||
}
|
||||
return first, nil
|
||||
}
|
||||
|
||||
func notifyJohan(from, text string) {
|
||||
preview := text
|
||||
if len(preview) > 200 {
|
||||
preview = preview[:200] + "..."
|
||||
}
|
||||
body := fmt.Sprintf("%s: %s", from, preview)
|
||||
req, _ := http.NewRequest("POST", "https://ntfy.inou.com/inou-alerts", strings.NewReader(body))
|
||||
req.Header.Set("Title", fmt.Sprintf("agentchat — %s", from))
|
||||
req.Header.Set("Authorization", "Bearer tk_k120jegay3lugeqbr9fmpuxdqmzx5")
|
||||
req.Header.Set("Click", "http://192.168.1.16:7777")
|
||||
http.DefaultClient.Do(req)
|
||||
}
|
||||
|
||||
func (h *Hub) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Println("upgrade:", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
username := r.URL.Query().Get("user")
|
||||
if username == "" {
|
||||
username = "anon"
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.clients[conn] = username
|
||||
h.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
h.mu.Lock()
|
||||
delete(h.clients, conn)
|
||||
h.mu.Unlock()
|
||||
}()
|
||||
|
||||
// Send recent history
|
||||
h.sendHistory(conn)
|
||||
|
||||
for {
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
var incoming struct {
|
||||
Text string `json:"text"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &incoming); err != nil || incoming.Text == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
msg := Message{
|
||||
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
|
||||
User: username,
|
||||
Text: incoming.Text,
|
||||
To: incoming.To,
|
||||
}
|
||||
|
||||
h.logMessage(msg)
|
||||
h.broadcast(msg)
|
||||
|
||||
// Route to agent(s)
|
||||
if incoming.To != "" {
|
||||
h.sendToAgent(msg, incoming.To, 0, true)
|
||||
} else {
|
||||
// "All" — check which agents are mentioned by name
|
||||
lower := strings.ToLower(incoming.Text)
|
||||
var targets []string
|
||||
for name, cfg := range agents {
|
||||
if strings.Contains(lower, strings.ToLower(cfg.Name)) {
|
||||
targets = append(targets, name)
|
||||
}
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
// No names mentioned — broadcast to all
|
||||
for name := range agents {
|
||||
targets = append(targets, name)
|
||||
}
|
||||
}
|
||||
for _, name := range targets {
|
||||
h.sendToAgent(msg, name, 999, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) sendHistory(conn *websocket.Conn) {
|
||||
f, err := os.Open("chat.log")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Read last 100 lines
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
if len(lines) > 100 {
|
||||
lines = lines[len(lines)-100:]
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
msg := parseLogLine(line)
|
||||
if msg != nil {
|
||||
data, _ := json.Marshal(msg)
|
||||
conn.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseLogLine(line string) *Message {
|
||||
// [2006-01-02 15:04:05] [user] text
|
||||
if len(line) < 24 || line[0] != '[' {
|
||||
return nil
|
||||
}
|
||||
tsEnd := strings.Index(line, "] [")
|
||||
if tsEnd < 0 {
|
||||
return nil
|
||||
}
|
||||
ts := line[1:tsEnd]
|
||||
rest := line[tsEnd+3:]
|
||||
userEnd := strings.Index(rest, "] ")
|
||||
if userEnd < 0 {
|
||||
return nil
|
||||
}
|
||||
return &Message{
|
||||
Timestamp: ts,
|
||||
User: rest[:userEnd],
|
||||
Text: rest[userEnd+2:],
|
||||
}
|
||||
}
|
||||
|
||||
// Agent-to-agent endpoint: POST /api/send
|
||||
func (h *Hub) handleAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "POST only", 405)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad json", 400)
|
||||
return
|
||||
}
|
||||
if req.From == "" || req.Text == "" {
|
||||
http.Error(w, "from and text required", 400)
|
||||
return
|
||||
}
|
||||
|
||||
msg := Message{
|
||||
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
|
||||
User: req.From,
|
||||
Text: req.Text,
|
||||
To: req.To,
|
||||
}
|
||||
h.logMessage(msg)
|
||||
h.broadcast(msg)
|
||||
|
||||
// If addressed to an agent, route it
|
||||
if req.To != "" {
|
||||
h.sendToAgent(msg, req.To, 0, true)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Register gateways (plain HTTP — OpenAI-compatible API)
|
||||
gatewayPool.Register("forge", &GatewayConfig{
|
||||
URL: "http://127.0.0.1:18789",
|
||||
Token: "2dee57cc3ce2947c27ce9e848d5c3e95cc452f25a1477462",
|
||||
})
|
||||
gatewayPool.Register("vault1984-hq", &GatewayConfig{
|
||||
URL: "http://100.85.192.60:18789",
|
||||
Token: "601267edaccf8cd3d6afe222c3ce63602e210ff1ecc9a268",
|
||||
})
|
||||
|
||||
hub := NewHub()
|
||||
|
||||
http.HandleFunc("/ws", hub.handleWS)
|
||||
http.HandleFunc("/api/send", hub.handleAPI)
|
||||
http.HandleFunc("/api/agents", func(w http.ResponseWriter, r *http.Request) {
|
||||
list := make([]map[string]string, 0)
|
||||
for id, a := range agents {
|
||||
list = append(list, map[string]string{"id": id, "name": a.Name, "host": a.Host})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(list)
|
||||
})
|
||||
// Shared scratchpad
|
||||
http.HandleFunc("/api/pad", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
entries := make([]PadEntry, 0)
|
||||
hub.pad.Range(func(k, v any) bool {
|
||||
entries = append(entries, v.(PadEntry))
|
||||
return true
|
||||
})
|
||||
json.NewEncoder(w).Encode(entries)
|
||||
case "POST":
|
||||
var req struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Author string `json:"author"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Key == "" {
|
||||
http.Error(w, "key required", 400)
|
||||
return
|
||||
}
|
||||
entry := PadEntry{
|
||||
Key: req.Key,
|
||||
Value: req.Value,
|
||||
Author: req.Author,
|
||||
UpdatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
hub.pad.Store(req.Key, entry)
|
||||
// Broadcast pad update
|
||||
hub.broadcast(Message{
|
||||
Timestamp: entry.UpdatedAt,
|
||||
User: req.Author,
|
||||
Text: fmt.Sprintf("pad/%s = %s", req.Key, req.Value),
|
||||
Kind: "status",
|
||||
})
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
case "DELETE":
|
||||
key := r.URL.Query().Get("key")
|
||||
if key != "" {
|
||||
hub.pad.Delete(key)
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
})
|
||||
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "index.html")
|
||||
})
|
||||
|
||||
port := "7777"
|
||||
if p := os.Getenv("PORT"); p != "" {
|
||||
port = p
|
||||
}
|
||||
log.Printf("agentchat listening on :%s", port)
|
||||
log.Fatal(http.ListenAndServe(":"+port, nil))
|
||||
}
|
||||
Loading…
Reference in New Issue