193 lines
6.6 KiB
HTML
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>
|