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:
James 2026-03-08 04:17:02 -04:00
commit a0cc49f4c9
6 changed files with 1018 additions and 0 deletions

15
agentchat.service Normal file
View File

@ -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

97
gateway.go Normal file
View File

@ -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
}

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module agentchat
go 1.23.6
require github.com/gorilla/websocket v1.5.3

2
go.sum Normal file
View File

@ -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=

372
index.html Normal file
View File

@ -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()">&#9654;</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>

527
main.go Normal file
View File

@ -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))
}