picogent/client.html
2026-02-21 02:41:39 +03:00

193 lines
6.6 KiB
HTML

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>picogent client</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Consolas', 'Monaco', monospace; background: #1a1a2e; color: #e0e0e0; height: 100vh; display: flex; flex-direction: column; }
header { padding: 12px 16px; background: #16213e; border-bottom: 1px solid #333; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
header h1 { font-size: 16px; color: #a8b2d1; }
.status { font-size: 12px; padding: 2px 8px; border-radius: 10px; }
.status.connected { background: #1b4332; color: #52b788; }
.status.disconnected { background: #3d0000; color: #e74c3c; }
.settings { display: flex; gap: 8px; margin-left: auto; align-items: center; }
.settings label { font-size: 11px; color: #777; }
.settings input { background: #0f3460; border: 1px solid #444; color: #e0e0e0; padding: 3px 8px; border-radius: 4px; font-family: inherit; font-size: 12px; width: 140px; }
#messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
.msg { padding: 8px 12px; border-radius: 6px; max-width: 85%; word-wrap: break-word; white-space: pre-wrap; font-size: 13px; line-height: 1.5; }
.msg.user { background: #0f3460; align-self: flex-end; color: #a8d8ea; }
.msg.assistant { background: #1f2833; align-self: flex-start; border: 1px solid #333; }
.msg.tool { background: #1a1a2e; align-self: flex-start; color: #f0a500; font-size: 12px; border-left: 3px solid #f0a500; }
.msg.error { background: #2d0000; align-self: flex-start; color: #e74c3c; border-left: 3px solid #e74c3c; }
.msg.system { background: transparent; align-self: center; color: #555; font-size: 11px; }
.input-area { padding: 12px 16px; background: #16213e; border-top: 1px solid #333; display: flex; gap: 8px; }
#prompt { flex: 1; background: #0f3460; border: 1px solid #444; color: #e0e0e0; padding: 10px 14px; border-radius: 6px; font-family: inherit; font-size: 14px; resize: none; outline: none; min-height: 44px; max-height: 120px; }
#prompt:focus { border-color: #a8d8ea; }
#prompt::placeholder { color: #555; }
button { background: #e94560; color: #fff; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: bold; }
button:hover { background: #c73e54; }
button:disabled { background: #444; cursor: not-allowed; }
</style>
</head>
<body>
<header>
<h1>picogent</h1>
<span id="status" class="status disconnected">disconnected</span>
<div class="settings">
<label>session:</label>
<input id="sessionId" placeholder="(optional)" value="">
<label>workDir:</label>
<input id="workDir" placeholder="(default)" value="">
</div>
</header>
<div id="messages"></div>
<div class="input-area">
<textarea id="prompt" rows="1" placeholder="Type a message... (Shift+Enter for newline)"></textarea>
<button id="send">Send</button>
</div>
<script>
const WS_URL = 'ws://localhost:3100';
const messagesEl = document.getElementById('messages');
const promptEl = document.getElementById('prompt');
const sendBtn = document.getElementById('send');
const statusEl = document.getElementById('status');
const sessionIdEl = document.getElementById('sessionId');
const workDirEl = document.getElementById('workDir');
let ws = null;
let msgCounter = 0;
// Track the current assistant response being streamed
let currentAssistantEl = null;
let currentAssistantId = null;
// Auto-generate session ID so conversation persists across messages
if (!sessionIdEl.value.trim()) {
sessionIdEl.value = 'chat-' + Math.random().toString(36).slice(2, 10);
}
function connect() {
ws = new WebSocket(WS_URL);
ws.onopen = () => {
statusEl.textContent = 'connected';
statusEl.className = 'status connected';
addMsg('system', 'Connected to ' + WS_URL);
};
ws.onclose = () => {
statusEl.textContent = 'disconnected';
statusEl.className = 'status disconnected';
addMsg('system', 'Disconnected. Reconnecting in 3s...');
currentAssistantEl = null;
currentAssistantId = null;
setTimeout(connect, 3000);
};
ws.onerror = () => {
// onclose will fire after this
};
ws.onmessage = (event) => {
let data;
try {
data = JSON.parse(event.data);
} catch {
addMsg('error', 'Bad JSON from server: ' + event.data);
return;
}
handleResponse(data);
};
}
function handleResponse(data) {
const { id, type, content, sessionId } = data;
if (type === 'text') {
// Accumulate text into a single assistant bubble per message id
if (currentAssistantId === id && currentAssistantEl) {
currentAssistantEl.textContent += content;
} else {
currentAssistantEl = addMsg('assistant', content);
currentAssistantId = id;
}
} else if (type === 'tool_use') {
addMsg('tool', '⚙ ' + content);
// Reset accumulator so next text chunk starts a new bubble after tool
currentAssistantEl = null;
currentAssistantId = null;
} else if (type === 'error') {
addMsg('error', content);
currentAssistantEl = null;
currentAssistantId = null;
} else if (type === 'done') {
if (sessionId) {
addMsg('system', 'done (session: ' + sessionId.slice(0, 8) + '...)');
}
sendBtn.disabled = false;
currentAssistantEl = null;
currentAssistantId = null;
}
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function addMsg(cls, text) {
const el = document.createElement('div');
el.className = 'msg ' + cls;
el.textContent = text;
messagesEl.appendChild(el);
messagesEl.scrollTop = messagesEl.scrollHeight;
return el;
}
function sendMessage() {
const content = promptEl.value.trim();
if (!content || !ws || ws.readyState !== WebSocket.OPEN) return;
const msg = {
id: 'msg-' + (++msgCounter),
content,
};
const sid = sessionIdEl.value.trim();
if (sid) msg.sessionId = sid;
const wd = workDirEl.value.trim();
if (wd) msg.workDir = wd;
addMsg('user', content);
ws.send(JSON.stringify(msg));
promptEl.value = '';
promptEl.style.height = 'auto';
sendBtn.disabled = true;
currentAssistantEl = null;
currentAssistantId = null;
}
sendBtn.addEventListener('click', sendMessage);
promptEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// Auto-resize textarea
promptEl.addEventListener('input', () => {
promptEl.style.height = 'auto';
promptEl.style.height = Math.min(promptEl.scrollHeight, 120) + 'px';
});
connect();
</script>
</body>
</html>