559 lines
18 KiB
HTML
559 lines
18 KiB
HTML
<!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 .meta .to-badge {
|
|
font-size: 10px; padding: 1px 6px; border-radius: 8px;
|
|
background: #1a2a3a; color: var(--accent); margin-left: 4px;
|
|
}
|
|
.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 .bubble img {
|
|
display: block; max-width: 100%; max-height: 320px;
|
|
border-radius: 8px; margin-top: 6px; cursor: pointer;
|
|
}
|
|
.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;
|
|
}
|
|
/* Room tab bar */
|
|
#room-bar {
|
|
display: flex; gap: 2px;
|
|
padding: 6px 10px;
|
|
background: var(--surface);
|
|
border-bottom: 1px solid var(--border);
|
|
overflow-x: auto; flex-shrink: 0;
|
|
-webkit-overflow-scrolling: touch;
|
|
}
|
|
#room-bar::-webkit-scrollbar { display: none; }
|
|
.room-btn {
|
|
flex-shrink: 0; padding: 5px 14px;
|
|
border-radius: 16px; border: 1px solid transparent;
|
|
background: transparent; color: var(--muted);
|
|
font-size: 13px; cursor: pointer; transition: all 0.15s;
|
|
position: relative; white-space: nowrap;
|
|
}
|
|
.room-btn:hover { color: var(--text); background: #1a1a1a; }
|
|
.room-btn.active { background: var(--accent); color: #fff; }
|
|
.room-btn .unread {
|
|
display: none;
|
|
width: 7px; height: 7px; border-radius: 50%;
|
|
background: #ff5252;
|
|
position: absolute; top: 2px; right: 4px;
|
|
}
|
|
.room-btn.has-unread .unread { display: block; }
|
|
.room-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;
|
|
}
|
|
.room-btn.is-thinking .thinking-dot { display: block; }
|
|
|
|
/* Image preview above compose */
|
|
#image-preview-bar {
|
|
display: none;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 6px 0;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
#image-preview-bar.visible { display: flex; }
|
|
#preview-thumb {
|
|
width: 52px; height: 52px; object-fit: cover;
|
|
border-radius: 8px; border: 1px solid var(--border);
|
|
}
|
|
#preview-remove {
|
|
width: 22px; height: 22px; border-radius: 50%;
|
|
border: none; background: #444; color: #fff;
|
|
font-size: 14px; cursor: pointer; line-height: 22px;
|
|
text-align: center; flex-shrink: 0;
|
|
}
|
|
#preview-label { font-size: 12px; color: var(--muted); }
|
|
|
|
#compose { display: flex; gap: 8px; align-items: flex-end; }
|
|
#attach-btn {
|
|
width: 40px; height: 40px; border-radius: 50%;
|
|
border: 1px solid var(--border); background: transparent;
|
|
color: var(--muted); font-size: 20px; cursor: pointer;
|
|
flex-shrink: 0; display: flex; align-items: center; justify-content: center;
|
|
transition: all 0.15s;
|
|
}
|
|
#attach-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
#attach-input { display: none; }
|
|
#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; }
|
|
|
|
/* Lightbox */
|
|
#lightbox {
|
|
display: none;
|
|
position: fixed; inset: 0; z-index: 200;
|
|
background: rgba(0,0,0,0.9);
|
|
align-items: center; justify-content: center;
|
|
cursor: zoom-out;
|
|
}
|
|
#lightbox.open { display: flex; }
|
|
#lightbox img { max-width: 95vw; max-height: 95vh; border-radius: 8px; }
|
|
|
|
#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>
|
|
<nav id="room-bar">
|
|
<button class="room-btn active" data-room="group" onclick="switchRoom(this)"># group<span class="unread"></span></button>
|
|
</nav>
|
|
<div id="messages"></div>
|
|
<div id="thinking-bar"><span></span></div>
|
|
<div id="input-area">
|
|
<div id="image-preview-bar">
|
|
<img id="preview-thumb" src="" alt="preview">
|
|
<div>
|
|
<div id="preview-label">Image attached</div>
|
|
</div>
|
|
<button id="preview-remove" onclick="clearImage()" title="Remove">✕</button>
|
|
</div>
|
|
<div id="compose">
|
|
<button id="attach-btn" onclick="document.getElementById('attach-input').click()" title="Attach image">🖼</button>
|
|
<input type="file" id="attach-input" accept="image/*" onchange="handleFileSelect(this)">
|
|
<textarea id="msg-input" rows="1" placeholder="Message..." enterkeyhint="send"></textarea>
|
|
<button id="send-btn" onclick="send()">▶</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Lightbox for full-size images -->
|
|
<div id="lightbox" onclick="closeLightbox()">
|
|
<img id="lightbox-img" src="" alt="">
|
|
</div>
|
|
|
|
<script>
|
|
const AGENTS = {james: 'James', mira: 'Mira', hans: 'Hans'};
|
|
const AGENT_EMOJI = {james: '⚡', mira: '✨', hans: '🔧'};
|
|
const thinkingAgents = new Set();
|
|
let ws, username;
|
|
let currentRoom = 'group';
|
|
let pendingImageURL = null;
|
|
// messages keyed by room for client-side filtering
|
|
const roomMessages = {}; // room -> [{el, msg}]
|
|
|
|
// Build DM room buttons
|
|
const roomBar = document.getElementById('room-bar');
|
|
for (const [id, name] of Object.entries(AGENTS)) {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'room-btn';
|
|
btn.dataset.room = `dm:${name}`;
|
|
btn.innerHTML = `${AGENT_EMOJI[id]} ${name}<span class="unread"></span><span class="thinking-dot"></span>`;
|
|
btn.onclick = function() { switchRoom(this); };
|
|
roomBar.appendChild(btn);
|
|
}
|
|
|
|
function switchRoom(btn) {
|
|
document.querySelectorAll('.room-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
btn.classList.remove('has-unread');
|
|
currentRoom = btn.dataset.room;
|
|
|
|
// Show/hide messages for this room
|
|
const cont = document.getElementById('messages');
|
|
cont.innerHTML = '';
|
|
(roomMessages[currentRoom] || []).forEach(({el}) => cont.appendChild(el.cloneNode(true)));
|
|
cont.scrollTop = cont.scrollHeight;
|
|
|
|
const dmAgent = dmAgentFromRoom(currentRoom);
|
|
document.getElementById('msg-input').placeholder =
|
|
dmAgent ? `Message ${dmAgent}...` : 'Message group...';
|
|
document.getElementById('msg-input').focus();
|
|
}
|
|
|
|
function dmAgentFromRoom(room) {
|
|
if (!room.startsWith('dm:')) return null;
|
|
const name = room.replace('dm:', '').split('-').find(n =>
|
|
Object.values(AGENTS).includes(n)
|
|
);
|
|
return name || null;
|
|
}
|
|
|
|
function updateThinkingBar() {
|
|
const bar = document.getElementById('thinking-bar');
|
|
// Only show thinking for agents active in current room
|
|
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 thinking dots on room buttons
|
|
document.querySelectorAll('.room-btn').forEach(btn => {
|
|
const room = btn.dataset.room;
|
|
const agentName = dmAgentFromRoom(room);
|
|
btn.classList.toggle('is-thinking', agentName ? thinkingAgents.has(agentName) : false);
|
|
});
|
|
}
|
|
|
|
// ── Image handling ───────────────────────────────────────────────
|
|
|
|
function handleFileSelect(input) {
|
|
const file = input.files[0];
|
|
if (file) uploadImage(file);
|
|
input.value = ''; // reset so same file can be re-selected
|
|
}
|
|
|
|
// Paste handler — catches Ctrl+V images
|
|
document.addEventListener('paste', (e) => {
|
|
const items = e.clipboardData && e.clipboardData.items;
|
|
if (!items) return;
|
|
for (const item of items) {
|
|
if (item.type.startsWith('image/')) {
|
|
e.preventDefault();
|
|
const file = item.getAsFile();
|
|
if (file) uploadImage(file);
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
|
|
async function uploadImage(file) {
|
|
const formData = new FormData();
|
|
formData.append('file', file, file.name || 'paste.png');
|
|
try {
|
|
const res = await fetch('/api/upload', { method: 'POST', body: formData });
|
|
if (!res.ok) throw new Error('upload failed');
|
|
const data = await res.json();
|
|
setPendingImage(data.url);
|
|
} catch (err) {
|
|
alert('Image upload failed: ' + err.message);
|
|
}
|
|
}
|
|
|
|
function setPendingImage(url) {
|
|
pendingImageURL = url;
|
|
const preview = document.getElementById('preview-thumb');
|
|
preview.src = url;
|
|
document.getElementById('image-preview-bar').classList.add('visible');
|
|
document.getElementById('msg-input').focus();
|
|
}
|
|
|
|
function clearImage() {
|
|
pendingImageURL = null;
|
|
document.getElementById('preview-thumb').src = '';
|
|
document.getElementById('image-preview-bar').classList.remove('visible');
|
|
}
|
|
|
|
// ── Lightbox ─────────────────────────────────────────────────────
|
|
|
|
function openLightbox(src) {
|
|
document.getElementById('lightbox-img').src = src;
|
|
document.getElementById('lightbox').classList.add('open');
|
|
}
|
|
function closeLightbox() {
|
|
document.getElementById('lightbox').classList.remove('open');
|
|
}
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') closeLightbox();
|
|
});
|
|
|
|
// ── Auth & connection ─────────────────────────────────────────────
|
|
|
|
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) {
|
|
const room = msg.room || 'group';
|
|
|
|
if (msg.kind === 'thinking') {
|
|
thinkingAgents.add(msg.user);
|
|
updateThinkingBar();
|
|
return;
|
|
}
|
|
if (msg.kind === 'thinking-done') {
|
|
thinkingAgents.delete(msg.user);
|
|
updateThinkingBar();
|
|
return;
|
|
}
|
|
if (Object.values(AGENTS).includes(msg.user)) {
|
|
thinkingAgents.delete(msg.user);
|
|
updateThinkingBar();
|
|
}
|
|
appendMessage(msg);
|
|
}
|
|
|
|
function appendMessage(msg) {
|
|
const room = msg.room || 'group';
|
|
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';
|
|
const imgHTML = msg.image_url
|
|
? `<img src="${esc(msg.image_url)}" alt="screenshot" onclick="openLightbox('${esc(msg.image_url)}')">`
|
|
: '';
|
|
const textHTML = msg.text ? esc(msg.text) : '';
|
|
|
|
el.innerHTML = `
|
|
<div class="meta">
|
|
<span class="name ${nameClass}">${esc(msg.user)}</span>
|
|
<span>${esc(msg.timestamp)}</span>
|
|
</div>
|
|
<div class="bubble">${textHTML}${imgHTML}</div>
|
|
`;
|
|
|
|
// Store in per-room cache
|
|
if (!roomMessages[room]) roomMessages[room] = [];
|
|
roomMessages[room].push({el, msg});
|
|
|
|
// Only render if we're in this room
|
|
if (room === currentRoom) {
|
|
const cont = document.getElementById('messages');
|
|
const atBottom = cont.scrollTop + cont.clientHeight >= cont.scrollHeight - 60;
|
|
cont.appendChild(el);
|
|
if (atBottom) cont.scrollTop = cont.scrollHeight;
|
|
} else {
|
|
// Mark room button as having unread
|
|
const btn = document.querySelector(`.room-btn[data-room="${CSS.escape(room)}"]`);
|
|
if (btn && !btn.classList.contains('active')) btn.classList.add('has-unread');
|
|
}
|
|
}
|
|
|
|
// ── Send ──────────────────────────────────────────────────────────
|
|
|
|
function send() {
|
|
const input = document.getElementById('msg-input');
|
|
const text = input.value.trim();
|
|
if ((!text && !pendingImageURL) || !ws || ws.readyState !== 1) return;
|
|
|
|
const payload = { text, room: currentRoom };
|
|
if (pendingImageURL) payload.image_url = pendingImageURL;
|
|
|
|
ws.send(JSON.stringify(payload));
|
|
input.value = '';
|
|
input.style.height = 'auto';
|
|
clearImage();
|
|
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) {
|
|
if (!s) return '';
|
|
const d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|