React Native / Expo¶
Полный путь: установка пакета, клиент с переподключением, экран чата с лентами, карточки с
кнопками, приём и показ медиа, правка пузыря при стриминге, аватар в WebView и запись голоса.
Код — прямое продолжение примера examples/react-native/HermesChat.tsx: там всё то же, но
здесь собрано в готовые шаги с разбором подводных камней. Псевдокода нет — каждый фрагмент
компилируется при подключённых зависимостях.
0. Что нужно от владельца агента¶
| Что | Пример |
|---|---|
| Адрес | wss://hermes-go.com/mobile-ws (пилот) или ваш домен из MOBILE_HOST/MOBILE_PORT/MOBILE_PATH |
| Токен | 64 hex-символа из MOBILE_TOKEN в ~/.hermes/.env |
device.id |
постоянный идентификатор, который выбираете вы, напр. my-app-1 |
Токен в документации не приводится — его выдаёт владелец агента отдельно. device.id держите
постоянным: от него зависит сессия Hermes (mobile:<device.id>) и адрес cron-доставки.
1. Установка¶
npm i hermes-mobile-client # клиент протокола (без рантайм-зависимостей)
npx expo install expo-file-system react-native-webview
npm i react-native-live-audio-stream # запись голоса (WAV 16 кГц)
hermes-mobile-client использует стандартный WebSocket и работает в Expo из коробки.
Для сохранения медиа нужен expo-file-system; для аватара — react-native-webview; для
записи голоса — react-native-live-audio-stream (нативный модуль, требует dev-build, не Expo Go).
2. Клиент с переподключением¶
Переподключение с экспоненциальной паузой уже встроено в HermesClient — не пишите своё.
Важно только правильно отреагировать на отказ в доступе (код 1008) и не долбить сервер.
// src/hermes/client.ts
import { HermesClient, type ConnectionState } from 'hermes-mobile-client';
const WS_URL = 'wss://hermes-go.com/mobile-ws';
const TOKEN = process.env.EXPO_PUBLIC_MOBILE_TOKEN ?? ''; // не хардкодьте токен в репозиторий
const DEVICE = { id: 'my-app-1', name: 'Телефон', platform: 'android', appVersion: '1.0.0' };
export function createClient(handlers: ConstructorParameters<typeof HermesClient>[0]['handlers']) {
const client = new HermesClient({
url: WS_URL,
token: TOKEN,
device: DEVICE,
maxBackoffMs: 15000, // потолок паузы между попытками
handlers: {
...handlers,
onState: (state: ConnectionState, detail?: string) => handlers?.onState?.(state, detail),
onError: (error) => {
// auth_failed приходит с fatal:true — сервер закрыл соединение кодом 1008.
// Повторять бессмысленно: показываем пользователю «нет доступа» и не reconnect'им.
if (error.code === 'auth_failed' || error.fatal) {
handlers?.onError?.({ ...error, message: 'Нет доступа: проверьте токен приложения' });
return;
}
handlers?.onError?.(error); // transport/offline — не фатально, переподключение уже идёт
},
},
});
client.connect();
return client;
}
Подводные камни:
auth_failedнаступает после закрытия 1008. SDK сам выставляет состояниеfailedи не переподключается — ваша задача показать текст, а не запускать цикл попыток заново.- Токен — секрет. В Expo читайте его из
EXPO_PUBLIC_*/EAS Secrets илиexpo-secure-store, не коммитьте вapp.json. device.idуникален на устройство. Два приложения с однимid— это одна сессия Hermes и «прыгающие» ответы.
3. Экран чата с лентами¶
Модель — один список Item: пользовательские и агентские пузыри, карточки и медиа.
Ключевой момент — upsert по id, чтобы и стриминг, и повторная доставка правили один пузырь.
// src/screens/ChatScreen.tsx
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FlatList, Image, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { WebView } from 'react-native-webview';
import * as FileSystem from 'expo-file-system';
import type { AgentMessage, AvatarState, MediaPayload, PromptDonePayload, PromptPayload } from 'hermes-mobile-client';
import { HermesClient } from 'hermes-mobile-client';
import { createClient } from '../hermes/client';
import { AVATAR_FALLBACK_HTML } from '../hermes/avatarHtml'; // см. examples/react-native/avatarHtml.ts
const MEDIA_DIR = `${FileSystem.documentDirectory}hermes-media/`;
type Role = 'user' | 'agent' | 'card' | 'media';
interface Item {
id: string; role: Role; text: string;
kind?: string; options?: PromptPayload['options']; resolved?: string;
mediaKind?: string; uri?: string; localPath?: string; caption?: string;
}
export function ChatScreen() {
const webViewRef = useRef<WebView | null>(null);
const clientRef = useRef<HermesClient | null>(null);
const [items, setItems] = useState<Item[]>([]);
const [avatarState, setAvatarState] = useState<AvatarState>('idle');
const [connection, setConnection] = useState('idle');
const [draft, setDraft] = useState('');
const upsert = useCallback((id: string, patch: Partial<Item> & { role: Role }) => {
setItems((prev) => {
const i = prev.findIndex((it) => it.id === id);
if (i === -1) return [...prev, { id, text: '', ...patch }];
const next = [...prev];
next[i] = { ...next[i], ...patch };
return next;
});
}, []);
useEffect(() => {
const client = createClient({
onState: setConnection,
onAvatar: setAvatarState,
onTyping: () => setAvatarState('thinking'),
onTypingStop: () => setAvatarState('idle'),
// Новое сообщение: дописываем в пузырь с тем же id (длинный ответ режется на куски).
onMessage: (m: AgentMessage) =>
setItems((prev) => {
const i = prev.findIndex((it) => it.id === m.id);
if (i !== -1) { const next = [...prev]; next[i] = { ...next[i], text: next[i].text + m.text }; return next; }
return [...prev, { id: m.id, role: 'agent', text: m.text }];
}),
// Стриминг-правка: заменяем текст пузыря, а не добавляем новый.
onMessageEdit: (m: AgentMessage) => upsert(m.id, { role: 'agent', text: m.text }),
onPrompt: (p: PromptPayload) =>
setItems((prev) => [...prev, { id: `card-${p.id}`, role: 'card', text: p.text, kind: p.kind, options: p.options }]),
onPromptDone: (d: PromptDonePayload) =>
upsert(`card-${d.id}`, { role: 'card', resolved: d.expired ? 'expired' : d.option_id ?? 'закрыта' }),
onMedia: (m: MediaPayload) => { void saveMedia(m); },
});
clientRef.current = client;
return () => client.disconnect();
}, [upsert]);
// ... saveMedia, answer, send — см. ниже
}
4. Карточки с кнопками¶
Карточка — это кадр prompt с готовыми options. Ответ — один кадр prompt с prompt_id
и option_id; отдельным text выбор дублировать нельзя.
function PromptCard({ item, onAnswer }: { item: Item; onAnswer: (it: Item, optionId: string) => void }) {
return (
<View style={[styles.card, item.resolved ? styles.cardResolved : null]}>
<Text style={styles.cardKind}>{item.kind}</Text>
<Text style={styles.cardText}>{item.text}</Text>
<View style={styles.options}>
{(item.options ?? []).map((o) => (
<Pressable key={o.id} disabled={Boolean(item.resolved)}
style={[styles.option, o.style === 'danger' ? styles.optionDanger : null]}
onPress={() => onAnswer(item, o.id)}>
<Text style={styles.optionLabel}>{o.label || o.id}</Text>
</Pressable>
))}
</View>
{item.resolved ? <Text style={styles.meta}>{item.resolved === 'expired' ? 'истекла' : `выбрано: ${item.resolved}`}</Text> : null}
</View>
);
}
// ответ:
const answer = useCallback((item: Item, optionId: string) => {
const promptId = item.id.replace(/^card-/, '');
const client = clientRef.current;
if (!client) return;
if (optionId === 'other') {
// clarify: вариант «свой ответ» требует текст — спросите его у пользователя.
// Здесь для краткости: text приходит из вашего диалога ввода.
const text = ''; // подставьте ввод из UI
if (!text.trim()) return;
client.answerPrompt(promptId, 'other', text);
} else {
client.answerPrompt(promptId, optionId);
}
upsert(item.id, { role: 'card', text: item.text, resolved: optionId });
}, [upsert]);
Подводный камень: вариант other обязан нести text. Отправите option_id: "other" без
текста — сервер переведёт карточку в режим ожидания текста, и она «зависнет».
5. Приём и показ медиа (expo-file-system)¶
Кадры media от агента приходят с part/parts; SDK собирает куски и вызывает onMedia
один раз на целый файл. Для картинок показываем data:-URL, всё остальное — сохраняем и
отдаём путь.
const saveMedia = useCallback(async (m: MediaPayload) => {
const name = m.filename || `${m.id}.bin`;
const dataUrl = m.data ? `data:${m.mime || 'application/octet-stream'};base64,${m.data}` : m.url;
let localPath: string | undefined;
try {
await FileSystem.makeDirectoryAsync(MEDIA_DIR, { intermediates: true });
localPath = `${MEDIA_DIR}${name}`;
if (m.data) {
await FileSystem.writeAsStringAsync(localPath, m.data, { encoding: FileSystem.EncodingType.Base64 });
}
} catch (error) {
console.warn('[hermes] не удалось сохранить медиа', error);
}
setItems((prev) => [...prev, {
id: `media-${m.id}-${Date.now()}`, role: 'media', text: '',
mediaKind: m.kind, uri: dataUrl, localPath, caption: m.caption || name,
}]);
}, []);
Подводные камни:
dataможет отсутствовать — тогда файл отдаётся ссылкойurl, скачивайте сами.- Не сохраняйте base64 в AsyncStorage — это раздувает хранилище; пишите файл через
expo-file-system, а в стейте держите путь. - Ориентируйтесь на
mime/filenameпри выборе расширения.
6. Отправка текста и медиа¶
const send = useCallback(() => {
const text = draft.trim();
if (!text || !clientRef.current) return;
clientRef.current.sendText(text); // id генерится внутри и повторяется при retry
setItems((prev) => [...prev, { id: `u-${Date.now()}`, role: 'user', text }]);
setDraft('');
}, [draft]);
// Фото/файл: base64 или путь. SDK сам нарежет на куски по 3 МБ.
async function sendPicked(uri: string, filename: string, mime: string, caption?: string) {
const base64 = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.Base64 });
const kind = mime.startsWith('image/') ? 'image' : mime.startsWith('video/') ? 'video' : 'document';
clientRef.current?.sendMedia(kind, base64, { mime, filename, caption });
}
Подводный камень: kind при отправке — только image / video / document / audio
(INBOUND_MEDIA_KINDS). voice относится к исходящим кадрам от агента — отправить его
вверх нельзя.
7. Аватар в WebView¶
Аватар — HTML-страница, реализующая контракт window.hermesAvatar:
window.hermesAvatar = {
setState: (state) => {}, // 'idle' | 'thinking' | 'speaking'
speak: (text) => {}, // попросить озвучить реплику
setVolume:(level) => {}, // уровень микрофона при записи, 0..1
};
URL страницы приходит в кадре ready (avatar_url). Если URL нет — используйте свою страницу
(её минимальная реализация — examples/react-native/avatarHtml.ts).
Главный подводный камень: страницу нужно инициализировать после ready. До этого
window.hermesAvatar в WebView не существует, и injectJavaScript молча ничего не сделает.
const [avatarHtml, setAvatarHtml] = useState<string | null>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
// в handlers.onReady:
onReady: (ready) => {
setAvatarUrl(ready.avatar_url || null);
// Если URL нет или он недоступен — падаем на встроенную страницу:
if (!ready.avatar_url) setAvatarHtml(AVATAR_FALLBACK_HTML);
},
useEffect(() => {
if (!avatarHtml && !avatarUrl) return; // ready ещё не пришёл — WebView не создан
webViewRef.current?.injectJavaScript(
`window.hermesAvatar && window.hermesAvatar.setState('${avatarState}'); true;`,
);
}, [avatarState, avatarHtml, avatarUrl]);
// В разметке:
{(avatarHtml || avatarUrl) ? (
<WebView
ref={webViewRef}
source={avatarHtml ? { html: avatarHtml } : { uri: avatarUrl! }}
style={styles.avatar} scrollEnabled={false} javaScriptEnabled
// можно слушать реплики страницы:
onMessage={(e) => { try { const m = JSON.parse(e.nativeEvent.data); if (m.type === 'avatar_ready') {/* контракт готов */} } catch {} }}
/>
) : null}
Подводные камни:
- WebView создаётся только после
ready— иначе инжект состояния уходит в никуда. Дополнительно можно дождаться сообщенияavatar_readyот страницы (onMessage). - Вызовы
injectJavaScriptвозвращайте сtrue;в конце — иначе iOS может считать промис неразрешённым. - Если аватарная страница внешняя (
avatar_url), она должна быть доступна по HTTPS — WebView не загрузитhttp://из production-сборки безApp Transport Security-исключений.
8. Запись голоса (WAV 16 кГц)¶
Протокол принимает audio только в wav или ogg, WAV — 16 кГц, моно, PCM 16 бит.
react-native-live-audio-stream отдаёт сырой PCM (base64) — его надо завернуть в WAV-заголовок.
// src/hermes/recorder.ts
import LiveAudioStream from 'react-native-live-audio-stream';
export function makeWavHeader(dataBytes: number, sampleRate = 16000, channels = 1, bits = 16): Uint8Array {
const byteRate = (sampleRate * channels * bits) / 8;
const blockAlign = (channels * bits) / 8;
const buffer = new ArrayBuffer(44);
const view = new DataView(buffer);
const ascii = (o: number, s: string) => { for (let i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i)); };
ascii(0, 'RIFF'); view.setUint32(4, 36 + dataBytes, true); ascii(8, 'WAVE');
ascii(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true);
view.setUint16(22, channels, true); view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true); view.setUint16(32, blockAlign, true); view.setUint16(34, bits, true);
ascii(36, 'data'); view.setUint32(40, dataBytes, true);
return new Uint8Array(buffer);
}
let pcm: Uint8Array[] = [];
let voiceClient: { sendAudio: (b64: string, fmt: 'wav' | 'ogg', text?: string) => string } | null = null;
export function startRecording(client: { sendAudio: (b64: string, fmt: 'wav' | 'ogg', text?: string) => string }) {
voiceClient = client; pcm = [];
LiveAudioStream.init({ sampleRate: 16000, channels: 1, bitsPerSample: 16, audioSource: 6, bufferSize: 4096 });
LiveAudioStream.on('data', (chunk: string) => pcm.push(base64ToBytes(chunk)));
LiveAudioStream.start();
}
export function stopAndSend() {
LiveAudioStream.stop();
const size = pcm.reduce((n, p) => n + p.length, 0);
if (size === 0 || !voiceClient) return;
const out = new Uint8Array(44 + size);
out.set(makeWavHeader(size), 0);
let offset = 44;
for (const part of pcm) { out.set(part, offset); offset += part.length; }
voiceClient.sendAudio(bytesToBase64(out), 'wav'); // bytesToBase64 из hermes-mobile-client
}
function base64ToBytes(input: string): Uint8Array {
const clean = input.replace(/[^A-Za-z0-9+/=]/g, '');
const raw = typeof atob === 'function' ? atob(clean) : Buffer.from(clean, 'base64').toString('binary');
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return typeof btoa === 'function' ? btoa(binary) : Buffer.from(bytes).toString('base64');
}
При записи отдавайте уровень микрофона аватару:
webViewRef.current?.injectJavaScript(\window.hermesAvatar.setVolume(${level}); true;`)`.
Подводные камни:
- Формат строго
wav/ogg—mp3/aacсервер отвергнет сunsupported_frame. - Аудио ≤ 8 МБ (примерно несколько минут). Длинную запись бейте на несколько
sendAudioс новым сообщением. - Если есть своё распознавание — передайте
textтретьим аргументом: агент получит и текст, и файл. КладитеtextвsendAudio, а не в отдельныйtext-кадр.
9. Сводка подводных камней¶
| Симптом | Причина | Решение |
|---|---|---|
| WebView не реагирует на состояние | инжект до ready |
создавать WebView только после ready |
| Дубли в лентах при стриминге | edit-кадры показаны как новые |
перерисовывать пузырь по id (onMessageEdit) |
| «Нет доступа», reconnect по кругу | не обработан auth_failed/1008 |
показать текст, не переподключаться |
| Карточка «зависла» | other без text |
дослать текст в том же кадре prompt |
| Медиа не сохранилось | data пуст, есть только url |
скачать по url |
| Аудио отклонено | не wav/ogg или > 8 МБ | WAV 16 кГц моно, разбить запись |