406 lines
12 KiB
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
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>
|