Браузер (голый WebSocket)¶
Минимальный клиент без библиотек: WebSocket + JSON. Эталонная рабочая страница со всеми
кадрами — examples/web/index.html; здесь разобраны ключевые места и ограничения браузерного
подхода.
1. Подключение и рукопожатие¶
Токен передаётся только в кадре hello — приём через ?token= в URL удалён (query-строка
попадает в логи reverse-proxy). Open-пример ниже так и делает.
<script>
(() => {
const PROTOCOL_VERSION = 1;
const MEDIA_CHUNK_BYTES = 3 * 1024 * 1024;
let socket = null;
function connect(url, token, deviceId) {
socket = new WebSocket(url); // токен в URL НЕ передаём
socket.onopen = () => socket.send(JSON.stringify({
t: 'hello', v: PROTOCOL_VERSION, token, // токен только здесь
device: { id: deviceId, name: 'Браузер', platform: 'web', app_version: '1.0.0' },
}));
socket.onmessage = (event) => handle(String(event.data));
socket.onerror = () => setStatus('Ошибка соединения: проверьте адрес и доступность порта');
socket.onclose = (event) => {
// 1008 — отказ в доступе; повторять бессмысленно.
setStatus(event.code === 1008 ? 'Доступ запрещён: неверный токен (код 1008)' : `Соединение закрыто (${event.code})`);
};
}
const send = (frame) => {
if (!socket || socket.readyState !== WebSocket.OPEN) { setStatus('Сначала подключитесь'); return false; }
socket.send(JSON.stringify(frame));
return true;
};
})();
</script>
2. Приём кадров¶
Разбор строго по t; неизвестный кадр — default и ничего. Поля необязательные читаем
защищённо (data.edit, data.stop).
function handle(raw) {
let data;
try { data = JSON.parse(raw); } catch { return; } // мусор игнорируем
switch (data.t) {
case 'ready':
setStatus(`Подключено. Сессия: ${data.session}` + (data.v !== 1 ? ` (шлюз v${data.v})` : ''));
// data.capabilities — источник истины: edit, media_in/out, prompts, max_media_bytes
break;
case 'typing':
setAvatar(data.stop ? 'idle' : 'thinking');
break;
case 'avatar':
setAvatar(data.state); // idle | thinking | speaking
break;
case 'message': {
const key = data.id;
const existing = document.querySelector(`[data-msg="${CSS.escape(key)}"]`);
if (data.edit) { // стриминг: заменяем текст
if (existing) existing.textContent = data.text;
else { const n = log(data.text); n.dataset.msg = key; }
} else if (existing) existing.textContent += data.text; // длинный ответ — кусками
else { const n = log(data.text); n.dataset.msg = key; }
break;
}
case 'prompt': showPrompt(data); break;
case 'prompt_done': finishPrompt(data.id, data.expired ? null : data.option_id); break;
case 'media':
if (data.parts > 1 && !data.final) break; // ждём последний кусок
showMedia(data);
break;
case 'error':
setStatus(`Ошибка ${data.code}: ${data.message}`);
break;
default: /* неизвестный кадр игнорируем */ break;
}
}
3. Карточки в HTML¶
Кнопки строятся из options; style: "danger" — красная, primary — акцентная. Ответ —
один кадр prompt. Для clarify вариант other спрашивает текст.
function showPrompt(data) {
const card = document.createElement('div');
card.className = 'card';
card.dataset.prompt = CSS.escape(data.id);
card.append(textEl('div', 'kind', `${data.kind} · ответьте кнопкой${data.timeout_seconds ? ` (до ${data.timeout_seconds} c)` : ''}`));
card.append(textEl('div', '', data.text));
const opts = document.createElement('div');
opts.className = 'opts';
(data.options || []).forEach((option) => {
const button = document.createElement('button');
button.textContent = option.label || option.id;
if (option.style === 'danger') button.className = 'danger';
if (option.style === 'primary') button.className = 'primary';
button.addEventListener('click', () => {
if (option.id === 'other') {
const answer = window.prompt('Свой ответ:') || '';
if (!answer.trim()) return; // other обязан нести непустой text
send({ t: 'prompt', prompt_id: data.id, option_id: 'other', text: answer });
} else {
send({ t: 'prompt', prompt_id: data.id, option_id: option.id });
}
finishPrompt(data.id, option.id);
});
opts.append(button);
});
card.append(opts);
log.appendChild(card);
}
4. Медиа: приём в blob¶
Файл от агента приходит base64 (или ссылкой url). Для больших файлов лучше собирать Blob
из байтов, а не data:-URL строкой — так не держим огромные строки в памяти дважды.
function base64ToBytes(base64) {
const raw = atob(base64);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
return bytes;
}
function showMedia(m) {
const box = document.createElement('div');
box.className = 'msg agent media';
let src;
if (m.data) {
const blob = new Blob([base64ToBytes(m.data)], { type: m.mime || 'application/octet-stream' });
src = URL.createObjectURL(blob); // не забудьте URL.revokeObjectURL(src) при удалении
} else {
src = m.url; // файл отдан ссылкой — качаем браузером
}
if (m.kind === 'image' || (m.mime || '').startsWith('image/')) {
const img = document.createElement('img'); img.src = src; img.alt = m.filename || 'изображение'; box.append(img);
} else if (m.kind === 'video' || (m.mime || '').startsWith('video/')) {
const v = document.createElement('video'); v.src = src; v.controls = true; box.append(v);
} else if (m.kind === 'voice' || (m.mime || '').startsWith('audio/')) {
const a = document.createElement('audio'); a.src = src; a.controls = true; box.append(a);
} else {
const link = document.createElement('a'); link.href = src; link.download = m.filename || 'файл';
link.textContent = `📄 ${m.filename || 'файл'}`; box.append(link);
}
log.appendChild(box);
}
5. Отправка файла кусками¶
function sendMediaFile(file) {
const reader = new FileReader();
reader.onload = () => {
const base64 = String(reader.result).split(',')[1] || '';
const bytes = base64ToBytes(base64);
const kind = file.type.startsWith('image/') ? 'image'
: file.type.startsWith('video/') ? 'video'
: file.type.startsWith('audio/') ? 'audio' : 'document';
const id = `m-${Date.now().toString(36)}`;
const parts = Math.max(1, Math.ceil(bytes.length / MEDIA_CHUNK_BYTES));
for (let index = 0; index < parts; index++) {
const chunk = bytes.slice(index * MEDIA_CHUNK_BYTES, (index + 1) * MEDIA_CHUNK_BYTES);
let binary = '';
for (let i = 0; i < chunk.length; i++) binary += String.fromCharCode(chunk[i]);
send({ t: 'media', id, kind, mime: file.type || 'application/octet-stream', filename: file.name,
data: btoa(binary), part: index, parts }); // досылать ВСЕ куски до parts-1
}
};
reader.readAsDataURL(file);
}
6. Ограничения браузерного варианта¶
- Токен читается из JS. Кто угодно с доступом к вкладке/DevTools (
localStorage,window) может его достать, а затем подключиться к шлюзу от вашего имени. Поэтому голый браузерный клиент годится только для своих (закрытая сеть, доверенные операторы) или для тестов и пилота — не для публичного веб-приложения. Для продакшена выдавайте токен из бэкенда на короткое время и проксируйте WebSocket через свой сервер, который один держитMOBILE_TOKEN. ws://наhttps://-странице браузер заблокирует (mixed content). Нуженwss://.- Нет переподключения из коробки — при обрыве
WebSocketне поднимается сам; реализуйте backoff вручную (как вHermesClient) или используйтеhermes-mobile-client, который это умеет и в браузере. - CORS не мешает WebSocket, но reverse-proxy должен корректно проксировать Upgrade-заголовки.
- Ограничение кадра — один WS-кадр ≤ 16 МБ, поэтому и в браузере медиа режется по 3 МБ.