james-dashboard/multi-chat.html

406 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multi-Agent Chat</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, sans-serif;
display: flex;
height: 100vh;
background: #1a1a2e;
color: #eee;
}
.sidebar {
width: 250px;
background: #16213e;
border-right: 1px solid #333;
display: flex;
flex-direction: column;
}
.sidebar h2 {
padding: 16px;
font-size: 14px;
text-transform: uppercase;
color: #888;
border-bottom: 1px solid #333;
}
.session-list { flex: 1; overflow-y: auto; }
.session-item {
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid #222;
display: flex;
align-items: center;
gap: 10px;
}
.session-item:hover { background: #1f3460; }
.session-item.active { background: #0f3460; border-left: 3px solid #e94560; }
.session-item .emoji { font-size: 20px; }
.session-item .name { flex: 1; }
.session-item .status {
width: 8px; height: 8px; border-radius: 50%; background: #666;
}
.session-item .status.streaming { background: #fbbf24; animation: pulse 1s infinite; }
@keyframes pulse { 50% { opacity: 0.5; } }
.main { flex: 1; display: flex; flex-direction: column; }
.header {
padding: 16px;
background: #16213e;
border-bottom: 1px solid #333;
display: flex;
align-items: center;
gap: 12px;
}
.header .emoji { font-size: 24px; }
.header .title { font-size: 18px; font-weight: 600; }
.header .conn-status { font-size: 12px; color: #888; margin-left: auto; }
.header .conn-status.ok { color: #4ade80; }
.messages { flex: 1; overflow-y: auto; padding: 16px; }
.message { margin-bottom: 16px; max-width: 80%; }
.message.user {
margin-left: auto;
background: #0f3460;
padding: 12px 16px;
border-radius: 16px 16px 4px 16px;
}
.message.assistant {
background: #1f1f3a;
padding: 12px 16px;
border-radius: 16px 16px 16px 4px;
}
.message.system {
text-align: center;
color: #888;
font-size: 12px;
max-width: 100%;
}
.message pre {
background: #111;
padding: 8px;
border-radius: 4px;
overflow-x: auto;
margin: 8px 0;
}
.input-area {
padding: 16px;
background: #16213e;
border-top: 1px solid #333;
display: flex;
gap: 12px;
}
.input-area input {
flex: 1;
padding: 12px 16px;
border: 1px solid #333;
border-radius: 8px;
background: #1a1a2e;
color: #eee;
font-size: 14px;
}
.input-area input:focus { outline: none; border-color: #e94560; }
.input-area button {
padding: 12px 24px;
background: #e94560;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
}
.input-area button:disabled { background: #666; cursor: not-allowed; }
</style>
</head>
<body>
<div class="sidebar">
<h2>Agents</h2>
<div class="session-list" id="session-list">Loading...</div>
</div>
<div class="main">
<div class="header" id="header">
<span class="emoji">💬</span>
<span class="title">Select an agent</span>
<span class="conn-status" id="conn-status">Connecting...</span>
</div>
<div class="messages" id="messages">
<div class="message system">Select an agent from the sidebar</div>
</div>
<div class="input-area">
<input type="text" id="input" placeholder="Type a message..." disabled>
<button id="send" disabled>Send</button>
</div>
</div>
<script>
const params = new URLSearchParams(window.location.search);
const GATEWAY_HOST = params.get('gateway') || '192.168.1.16:18789';
const GATEWAY_URL = 'ws://' + GATEWAY_HOST + '/ws';
const TOKEN = params.get('token') || '2dee57cc3ce2947c27ce9e848d5c3e95cc452f25a1477462';
let ws = null;
let connected = false;
let requestId = 0;
let pendingRequests = {};
let agents = []; // From /api/agents
let messageCache = {}; // sessionKey -> messages[]
let activeSession = null;
let streaming = false;
const sessionList = document.getElementById('session-list');
const header = document.getElementById('header');
const connStatus = document.getElementById('conn-status');
const messagesEl = document.getElementById('messages');
const input = document.getElementById('input');
const sendBtn = document.getElementById('send');
// Load agents from dashboard API (not WebSocket)
async function loadAgents() {
try {
const res = await fetch('/api/agents');
const data = await res.json();
agents = data.agents || [];
renderAgentList();
} catch (e) {
console.error('Failed to load agents:', e);
sessionList.innerHTML = '<div style="padding:16px;color:#888">Failed to load agents</div>';
}
}
function renderAgentList() {
sessionList.innerHTML = agents.map(a => {
const sessionKey = `agent:${a.id}:main`;
const isActive = sessionKey === activeSession;
const isStreaming = isActive && streaming;
return `
<div class="session-item ${isActive ? 'active' : ''}" data-key="${sessionKey}" data-name="${a.name}" data-emoji="${a.emoji}">
<span class="emoji">${a.emoji}</span>
<span class="name">${a.name}</span>
<span class="status ${isStreaming ? 'streaming' : ''}"></span>
</div>
`;
}).join('');
sessionList.querySelectorAll('.session-item').forEach(el => {
el.onclick = () => selectSession(el.dataset.key, el.dataset.name, el.dataset.emoji);
});
}
// WebSocket
function connect() {
connStatus.textContent = 'Connecting...';
connStatus.className = 'conn-status';
ws = new WebSocket(GATEWAY_URL);
ws.onopen = () => console.log('WS open, waiting for challenge...');
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
// Challenge
if (msg.type === 'event' && msg.event === 'connect.challenge') {
sendConnect(msg.payload.nonce);
return;
}
// Response
if (msg.type === 'res') {
const handler = pendingRequests[msg.id];
if (handler) {
delete pendingRequests[msg.id];
handler(msg);
}
return;
}
// Chat events (only for active session)
if (msg.type === 'event' && msg.event === 'chat' && activeSession) {
const payload = msg.payload || {};
if (payload.sessionKey !== activeSession) return;
handleChatEvent(payload);
}
};
ws.onclose = () => {
connected = false;
connStatus.textContent = 'Disconnected';
connStatus.className = 'conn-status';
setTimeout(connect, 3000);
};
ws.onerror = (e) => console.error('WS error:', e);
}
function sendConnect(nonce) {
const msg = {
type: 'req',
id: String(++requestId),
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
client: { id: 'openclaw-control-ui', version: '0.1.0', platform: 'web', mode: 'webchat', instanceId: crypto.randomUUID() },
role: 'operator',
scopes: ['operator.read', 'operator.write'],
caps: [], commands: [], permissions: {},
auth: { token: TOKEN },
locale: 'en-US',
userAgent: 'multi-chat/0.1'
}
};
pendingRequests[msg.id] = (res) => {
if (res.ok) {
connected = true;
connStatus.textContent = 'Connected';
connStatus.className = 'conn-status ok';
} else {
connStatus.textContent = 'Auth failed';
}
};
ws.send(JSON.stringify(msg));
}
function request(method, params) {
return new Promise((resolve, reject) => {
const id = String(++requestId);
const timeout = setTimeout(() => {
delete pendingRequests[id];
reject(new Error('Timeout'));
}, 15000);
pendingRequests[id] = (res) => {
clearTimeout(timeout);
if (res.ok) resolve(res.payload);
else reject(res.error);
};
ws.send(JSON.stringify({ type: 'req', id, method, params }));
});
}
function handleChatEvent(payload) {
const messages = messageCache[activeSession] || [];
if (payload.kind === 'text' || payload.kind === 'text_delta') {
streaming = true;
renderAgentList();
let last = messages[messages.length - 1];
if (!last || last.role !== 'assistant' || last.complete) {
last = { role: 'assistant', content: '', complete: false };
messages.push(last);
messageCache[activeSession] = messages;
}
if (payload.kind === 'text') last.content = payload.text || '';
else last.content += payload.delta || '';
renderMessages();
}
if (payload.kind === 'end' || payload.kind === 'error') {
streaming = false;
renderAgentList();
let last = messages[messages.length - 1];
if (last && last.role === 'assistant') last.complete = true;
renderMessages();
}
}
async function selectSession(key, name, emoji) {
activeSession = key;
streaming = false;
header.innerHTML = `
<span class="emoji">${emoji}</span>
<span class="title">${name}</span>
<span class="conn-status ${connected ? 'ok' : ''}" id="conn-status">${connected ? 'Connected' : 'Disconnected'}</span>
`;
input.disabled = false;
sendBtn.disabled = false;
renderAgentList();
// Load history if not cached
if (!messageCache[key]) {
messagesEl.innerHTML = '<div class="message system">Loading...</div>';
try {
const res = await request('chat.history', { sessionKey: key, limit: 100 });
console.log('chat.history response:', JSON.stringify(res, null, 2));
console.log('messages sample:', res.messages?.[0]);
messageCache[key] = (res.messages || []).map(m => ({
role: m.role,
content: typeof m.content === 'string' ? m.content : (m.content?.[0]?.text || JSON.stringify(m.content)),
complete: true
}));
} catch (e) {
console.error('History error:', e);
messageCache[key] = [];
}
}
renderMessages();
}
function renderMessages() {
const messages = messageCache[activeSession] || [];
if (messages.length === 0) {
messagesEl.innerHTML = '<div class="message system">No messages yet. Say hi!</div>';
return;
}
messagesEl.innerHTML = messages.map(m => {
const content = escapeHtml(m.content || '').replace(/\n/g, '<br>');
return `<div class="message ${m.role}">${content}</div>`;
}).join('');
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function escapeHtml(t) {
return t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
async function sendMessage() {
const text = input.value.trim();
if (!text || !activeSession || !connected) return;
const messages = messageCache[activeSession] || [];
messages.push({ role: 'user', content: text, complete: true });
messageCache[activeSession] = messages;
renderMessages();
input.value = '';
try {
await request('chat.send', {
sessionKey: activeSession,
message: text,
idempotencyKey: 'msg-' + Date.now()
});
} catch (e) {
console.error('Send error:', e);
}
}
sendBtn.onclick = sendMessage;
input.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); sendMessage(); } };
loadAgents();
connect();
</script>
</body>
</html>