@hubtalk-ai/web-sdk 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ru.md ADDED
@@ -0,0 +1,338 @@
1
+ # @hubtalk-ai/web-sdk
2
+
3
+ *[English version — `README.md`](README.md).*
4
+
5
+ Браузерный SDK: голосовой звонок с агентом HubTalk AI Conversation Flow
6
+ прямо со страницы вашего сайта. Тонкая обёртка над
7
+ [`livekit-client`](https://github.com/livekit/client-sdk-js); ключа Web API
8
+ SDK не держит и в HTTP-API платформы не ходит.
9
+
10
+ Интеграция состоит из трёх шагов:
11
+
12
+ 1. Ваш бэкенд зовёт `POST /v1/calls/web` ключом Web API воркспейса —
13
+ в теле `agent_id` и, если нужно, динамические переменные звонка.
14
+ 2. Платформа отвечает `201` и сессией: `{access_token, url, …}`.
15
+ 3. Страница отдаёт это тело в `startCall()` — дальше разговор идёт между
16
+ браузером и платформой, а состояние приходит событиями.
17
+
18
+ Ключ Web API остаётся на вашем сервере и в браузер не попадает. Контракт
19
+ эндпоинта — в схеме платформы: `{BASE_URL}/docs`.
20
+
21
+ ## Установка
22
+
23
+ ```bash
24
+ npm install @hubtalk-ai/web-sdk
25
+ ```
26
+
27
+ ```ts
28
+ import { VoiceClient } from '@hubtalk-ai/web-sdk';
29
+ ```
30
+
31
+ Без сборщика — самодостаточный бандл со своей копией `livekit-client`:
32
+
33
+ ```html
34
+ <script src="voice-client.bundle.js"></script>
35
+ <script>
36
+ const client = new VoiceClient();
37
+ </script>
38
+ ```
39
+
40
+ ## Быстрый старт
41
+
42
+ ```ts
43
+ const client = new VoiceClient();
44
+
45
+ startCallButton.addEventListener('click', async () => {
46
+ // session — то, что ваш бэкенд вернул из POST /v1/calls/web
47
+ const session = await fetch('/api/start-voice-call', { method: 'POST' }).then((r) => r.json());
48
+ await client.startCall(session);
49
+ });
50
+
51
+ client.on('transcript', ({ role, text, final, id }) => renderBubble(id, role, text, final));
52
+ client.on('callEnded', ({ reason }) => console.log('call ended:', reason));
53
+
54
+ endCallButton.addEventListener('click', () => client.stopCall());
55
+ ```
56
+
57
+ **`startCall` вызывайте внутри обработчика клика или тапа** — жест
58
+ посетителя нужен браузеру и для звука агента, и для доступа к микрофону.
59
+ На iOS Safari сетевой запрос между кликом и `startCall()` может этот жест
60
+ съесть: получайте сессию заранее.
61
+
62
+ ## API
63
+
64
+ ### `new VoiceClient(options?)`
65
+
66
+ | Опция | Тип | По умолчанию | Описание |
67
+ |---|---|---|---|
68
+ | `audioContainer` | `HTMLElement` | скрытый `<div>` от SDK | Куда добавляются `<audio>`-элементы агента. |
69
+ | `logger` | `{ error, debug }` | `console` | Свой приёмник логов; токены маскируются в любом случае. |
70
+ | `logLevel` | `'silent' \| 'error' \| 'debug'` | `'error'` | Подробный лог включается явно. |
71
+ | `connectTimeoutMs` | `number` | 15 с | Таймаут подключения. |
72
+
73
+ ### Методы
74
+
75
+ | Метод | Поведение |
76
+ |---|---|
77
+ | `startCall(session, options?)` | Подключается и публикует микрофон. Принимает ответ `POST /v1/calls/web` как есть (лишние поля игнорируются) либо `{accessToken, url}`. Второй вызов при активном звонке — `ALREADY_IN_CALL`. `options.audio: false` — звонок только текстом. |
78
+ | `stopCall()` | Завершает звонок. Без активного звонка ничего не делает и не даёт ошибки. |
79
+ | `sendText(text)` | Отправляет текстовую реплику посетителя; агент прерывает речь и отвечает на неё как на сказанное вслух. Вне звонка — `NOT_IN_CALL`; длиннее 1000 символов обрезается. Своё сообщение SDK в чат не кладёт: оно вернётся событием `transcript` с `role: 'user'`. |
80
+ | `getState()` | Состояние соединения: `idle \| connecting \| connected \| reconnecting \| disconnected \| error`. |
81
+ | `getAgentState()` | Чем занят агент: `unknown \| initializing \| listening \| thinking \| speaking`. |
82
+ | `getCallId()` | Идентификатор звонка (тот же, что в `GET /v1/calls/{call_id}`), или `null` до подключения. |
83
+ | `on` / `off` / `once` | Подписка, отписка, подписка на один раз. |
84
+ | `destroy()` | Останавливает звонок и освобождает ресурсы; экземпляр больше непригоден. |
85
+
86
+ ### События
87
+
88
+ | Событие | Полезная нагрузка |
89
+ |---|---|
90
+ | `callStarted` | `{ callId }` |
91
+ | `callEnded` | `{ callId, reason }` — `local` (`stopCall`), `remote` (завершил агент), `connection_lost`, `error` |
92
+ | `connectionStateChanged` | `{ state, previous }` |
93
+ | `agentStateChanged` | `{ state, previous }` |
94
+ | `agentSpeakingStarted` / `agentSpeakingStopped` | `{}` |
95
+ | `transcript` | `{ role, text, final, id }` — реплика разговора; `role`: `user` \| `agent`. Пока `final: false`, реплика растёт: рисуйте один пузырь на `id`. |
96
+ | `error` | `VoiceError` |
97
+
98
+ ### Звонок только текстом
99
+
100
+ `startCall(session, { audio: false })` — тот же звонок без микрофона и без
101
+ звука: разрешение браузер не спрашивает, разговор идёт текстом (`sendText()`
102
+ и `transcript`). Режим для посетителя, который не может или не хочет
103
+ говорить. Агент при этом всё равно синтезирует речь, поэтому текст приходит
104
+ в темпе речи, а звонок стоит столько же, сколько голосовой.
105
+
106
+ ```ts
107
+ await client.startCall(session, { audio: false });
108
+ ```
109
+
110
+ ### Коды ошибок
111
+
112
+ Любой отказ — `VoiceError`: `{ code, message, state, cause? }`. Разбирайте
113
+ `code`; `message` — служебная строка, а текст для посетителя пишете вы.
114
+
115
+ | Код | Когда |
116
+ |---|---|
117
+ | `INVALID_PARAMS` | В сессии нет `access_token` или `url` |
118
+ | `WEBRTC_UNSUPPORTED` | Браузер не поддерживает WebRTC либо страница не на HTTPS |
119
+ | `MICROPHONE_PERMISSION_DENIED` | Посетитель запретил микрофон |
120
+ | `MICROPHONE_UNAVAILABLE` | Микрофона нет или он занят |
121
+ | `TOKEN_EXPIRED` | Сессия протухла до начала звонка |
122
+ | `TOKEN_INVALID` | Сервер отклонил токен сессии |
123
+ | `CONNECTION_FAILED` | Не удалось подключиться |
124
+ | `CONNECTION_LOST` | Связь пропала и не восстановилась |
125
+ | `ALREADY_IN_CALL` | Звонок уже идёт |
126
+ | `NOT_IN_CALL` | `sendText` вне звонка |
127
+ | `SERVER_ERROR` | Прочий сбой сервиса |
128
+
129
+ Токен сессии живёт 2 часа и не обновляется: минтите свежую сессию на каждый
130
+ звонок, а не держите одну на открытой странице.
131
+
132
+ ## Ссылки-демо
133
+
134
+ Свой бэкенд нужен не всегда. Кабинет платформы выдаёт временную ссылку на
135
+ один опубликованный агент — со сроком, потолком числа звонков и отзывом в
136
+ один клик. Страница за ссылкой минтит сессию сама и ведёт звонок этим же
137
+ SDK, так что послушать агента можно без аккаунта, ключа и настройки.
138
+
139
+ Ссылка-демо — когда человеку нужно просто поговорить с агентом. Свой
140
+ бэкенд — когда звонок начинается с вашего сайта, на вашем домене, с
141
+ переменными, которые знает ваша система.
142
+
143
+ ## Пример интеграции
144
+
145
+ В `example/` лежит рабочая пара «бэкенд + страница», с которой удобно
146
+ начинать: `server.py` держит ключ Web API и минтит сессию, а страницы
147
+ показывают виджет звонка. `/` и `/avatar` используют готовый компонент SDK;
148
+ остальные страницы остаются примерами на основе headless-клиента.
149
+
150
+ ```bash
151
+ npm install && npm run build
152
+ cp .env.example .env # CFLOW_API_URL, CFLOW_API_KEY, CFLOW_AGENT_ID
153
+ python3 example/server.py # http://localhost:8000/
154
+ ```
155
+
156
+ | Страница | Что показывает |
157
+ |---|---|
158
+ | `/` | сайт с виджетом звонка в углу |
159
+ | `/avatar` | перетаскиваемый аватар: живой AI-аватар после соединения, клип ведущего из примера как failover |
160
+ | `/call` | звонок во всю страницу с брендированной колонкой |
161
+ | `/free` | то же без настроек: агент, переменные и ключ набираются в форме |
162
+ | `/file` | `/free`, плюс mp3 или wav, который играет в звонок вместо микрофона |
163
+
164
+ Что стоит унести из примера в свою интеграцию: ключ Web API живёт только на
165
+ сервере, браузер называет лишь значения переменных, а сервер принимает
166
+ значения только тех переменных, которые сам объявил. Компонент — `src/widget.ts`; `example/demo-widget.js` передаёт callback
167
+ бэкенда и использует формы из `example/demo-call.js`.
168
+
169
+ ### Демо-стенд
170
+
171
+ Сервер примера ставится под systemd unit'ом
172
+ `example/hubtalk-webrtc-example.service` (перед установкой поправьте `User`
173
+ и `WorkingDirectory`), за HTTPS-прокси — браузеру нужен защищённый контекст.
174
+ Пример конфигурации прокси — `example/caller-widget.demo.cyberdev.kz.Caddyfile`.
175
+
176
+ ```bash
177
+ npm run build # обязательно: dist/ в gitignore, git pull бандл не обновляет
178
+ sudo systemctl restart hubtalk-webrtc-example
179
+ ```
180
+
181
+ ## Готовый виджет (VPT-857)
182
+
183
+ **Что работает сегодня.** Аватарный режим работает с любой текущей
184
+ платформой HubTalk: круг играет встроенные клипы-failover (постер, петля
185
+ «слушает», петля «говорит», Video.js), а звонок — обычный голосовой. Живой
186
+ AI-аватар, описанный ниже, требует поддержки платформы, которая ещё не
187
+ выкачена; до тех пор каждый звонок показывает клипы, и с её появлением в
188
+ интеграции ничего не меняется — виджет сам переключится на живое видео.
189
+
190
+ **Попробовать за пять минут.**
191
+
192
+ 1. `npm ci && npm run build` в этом репозитории.
193
+ 2. Скопируйте `.env.example` в `.env` в корне репозитория, впишите Web API key и
194
+ имя агента.
195
+ 3. `python3 example/server.py`, откройте `http://127.0.0.1:8000/avatar`.
196
+ `/` — тот же виджет в стандартном режиме, `/free` — рукописный пример.
197
+ 4. Нажмите круг — звонок, ещё раз — mute; Chat открывает транскрипт и End
198
+ call. Замените три файла в `example/` своим постером и беззвучными
199
+ клипами (см. `example/AVATAR-MEDIA.md`), чтобы увидеть своего ведущего.
200
+
201
+ Опциональный экспорт — `@hubtalk-ai/web-sdk/widget`:
202
+ `mountWidget(options: WidgetOptions): WidgetHandle`; типы `AvatarMedia`,
203
+ `CreateSessionContext`, `WidgetOptions`, `WidgetHandle` экспортируются из
204
+ того же пути.
205
+
206
+ ```ts
207
+ import { mountWidget } from '@hubtalk-ai/web-sdk/widget';
208
+ import '@hubtalk-ai/web-sdk/widget.css';
209
+ const widget = mountWidget({
210
+ container: document.getElementById('support')!,
211
+ mode: 'avatar',
212
+ createSession: async ({ avatar }) => {
213
+ // avatar === true в аватарном режиме: бэкенд передаёт его как
214
+ // `avatar: true` в POST /v1/calls/web (см. «Живой аватар» ниже)
215
+ const response = await fetch('/api/session', {
216
+ method: 'POST',
217
+ headers: { 'Content-Type': 'application/json' },
218
+ body: JSON.stringify({ avatar }),
219
+ });
220
+ if (!response.ok) throw new Error('Session unavailable');
221
+ return response.json();
222
+ },
223
+ // клипы-failover: показываются, пока живого видео аватара нет
224
+ avatar: {
225
+ posterUrl: '/presenter/poster.jpg',
226
+ listeningVideoUrl: '/presenter/listening.mp4',
227
+ speakingVideoUrl: '/presenter/speaking.mp4',
228
+ size: 140, // диаметр круга в px, необязательно; это значение по умолчанию
229
+ },
230
+ });
231
+ // При удалении страницы/компонента:
232
+ widget.destroy();
233
+ ```
234
+
235
+ Без сборщика подключите `/sdk/widget.css` и `/sdk/widget.bundle.js` обычными
236
+ `link`/`script`; тот же метод доступен как `HubTalkWidget.mountWidget(options)`.
237
+ `container` и `createSession` обязательны; `mode` по умолчанию `standard`.
238
+ В режиме `avatar` обязательны все три URL (медиа failover); необязательный
239
+ `avatar.size` задаёт диаметр круга в пикселях (по умолчанию 140, на
240
+ телефонах 6/7 от него) — либо переменная `--ht-avatar-size` на `.ht-widget`
241
+ в вашем CSS. `createSession`
242
+ вызывается с `{ avatar: boolean }` — `true` в аватарном режиме, `false` в
243
+ стандартном; колбэки без аргумента продолжают работать.
244
+ `renderCallDetails(container)` опционально создаёт форму; callback сессии
245
+ читает её текущие значения через замыкание. `destroy()` идемпотентно
246
+ завершает звонок и удаляет свой DOM/медиа. Пример — `example/demo-widget.js`;
247
+ `/` показывает стандартный режим, `/avatar` — аватар. Web API key остаётся
248
+ на сервере.
249
+
250
+ ### Живой аватар
251
+
252
+ В аватарном режиме круг показывает **живое видео AI-аватара** — воркер
253
+ аватара LiveKit входит в звонок от имени агента и публикует видео с губами,
254
+ синхронизированными с голосом агента (провайдер Anam; стоковое лицо «Layla»
255
+ по умолчанию, настраивается на агенте). Аватар стартует, только когда верны
256
+ все три условия, иначе звонок идёт только со звуком и виджет показывает
257
+ клипы-failover:
258
+
259
+ 1. ваш бэкенд передаёт просьбу виджета как `"avatar": true` в теле
260
+ `POST /v1/calls/web` (для платформы это просьба, не гарантия; форма ответа
261
+ не меняется);
262
+ 2. у агента включена настройка **AI-аватар** в редакторе HubTalk
263
+ (Settings → Additional → AI-аватар; по умолчанию выключена, только
264
+ каскадный пайплайн);
265
+ 3. на контуре платформы настроен провайдер аватара.
266
+
267
+ Приоритет медиа, от старшего: **живое видео** → **клипы-failover**
268
+ (`listeningVideoUrl`/`speakingVideoUrl`) → **постер**. Откат на клипы —
269
+ штатное состояние, не ошибка: до звонка, секунды, пока аватар входит, агент
270
+ с выключенным аватаром, медленный или недоступный провайдер, пропавшее
271
+ посреди звонка видео. Пока идёт живое видео, клип-плеер стоит на паузе и
272
+ спрятан (а если клип ещё не понадобился — не загружается вовсе); когда видео
273
+ пропадает, клипы возобновляются. Живой `<video>` — muted, inline, без
274
+ controls: голос всегда идёт звуковым путём SDK, никогда из видеоэлемента,
275
+ поэтому слышен ровно один раз.
276
+
277
+ Headless-интеграции получают тот же сигнал от `VoiceClient`: событие
278
+ `agentVideoChanged: { available }` при подписке/пропадании видеотрека
279
+ воркера (сброс в `false` на конце звонка, при неудачном старте и
280
+ `destroy()`), метод `attachAgentVideo(element)` вешает текущий и любой
281
+ поздний трек на ваш `<video>` и возвращает функцию отсоединения (вне звонка —
282
+ no-op). Воркер — удалённый участник kind `agent`, чей атрибут
283
+ `lk.publish_on_behalf` равен identity агента; состояние агента остаётся на
284
+ его собственном `lk.agent.state`.
285
+
286
+ **Раскрытие.** Пока идёт живое видео, на круге видна подпись **AI avatar**,
287
+ она же — доступное описание круга. Вшитого водяного знака нет. Сообщить
288
+ посетителю, что он говорит с ИИ, там, где этого требует юрисдикция, —
289
+ обязанность интегратора; подпись помогает, но не заменяет её.
290
+
291
+ Живое видео приходит по уже установленному соединению LiveKit: новых
292
+ CSP-источников сверх `connect-src` и `media-src blob:` не нужно. Голос агента
293
+ и видео аватара проходят через облако провайдера — согласуйте это с
294
+ ответственным за данные перед включением настройки на регулируемом контуре.
295
+
296
+ Круг запускает голосовой звонок, затем включает/выключает только микрофон
297
+ посетителя. Голос агента продолжает звучать. Отдельная кнопка Chat открывает
298
+ панель; End call внутри завершает звонок. Кнопка Chat внутри панели начинает
299
+ текстовый звонок. Enter/Space активируют кнопки, фокус видим. Перетаскивание
300
+ не запускает звонок и не переключает микрофон; позиция не сохраняется.
301
+
302
+ Медиа failover — постер и два беззвучных H.264 MP4 одного человека с
303
+ разрешённым использованием образа как AI-презентера. Точной синхронизации
304
+ губ у клипов нет: speaking-клип следует состоянию агента; кольцо и текст
305
+ показывают listening/speaking/muted/idle/connecting. Thinking не называется
306
+ listening. При ошибке/запрете клипа и reduced motion остаётся постер, живое
307
+ видео при reduced motion сохраняется; управление звонком работает.
308
+ В пример включён ведущий из клипа Pexels (лицензия Pexels, источник и
309
+ скрипт регенерации — `example/AVATAR-MEDIA.md`); это медиа примера, а не
310
+ очищенный образ для вашего сайта — замените своим.
311
+
312
+ Video.js закреплён на 8.24.0. ESM загружает его только для аватара и только
313
+ при первой потребности в клипе;
314
+ самодостаточный IIFE содержит библиотеку целиком, но создаёт player только
315
+ для аватара. Headless-экспорты не импортируют виджет или Video.js. CSS
316
+ подключается явно и ограничен корнем виджета.
317
+
318
+ Нужен HTTPS. CSP: хост SDK — `script-src`/`style-src`, постер — `img-src`,
319
+ клипы — `media-src`, бэкенд и LiveKit HTTPS/WSS — `connect-src`;
320
+ для WebRTC нужен `media-src blob:`, для позиционирования и Video.js —
321
+ `style-src-attr 'unsafe-inline'`. CDN не требуется. Ограничения хоста на
322
+ медиа могут оставить только постер.
323
+
324
+ `isMicrophoneEnabled()` изначально false; `setMicrophoneEnabled(enabled)`
325
+ меняет только микрофон подключённого аудиозвонка. Событие
326
+ `microphoneStateChanged: { enabled }` отражает подтверждённый результат;
327
+ завершение сбрасывает false. Ошибка переключения сохраняет звонок и состояние.
328
+ Вне подключённого аудиозвонка — `NOT_IN_CALL`, включая reconnecting.
329
+
330
+ На iOS исходный жест может потеряться за запросом сессии. Событие
331
+ `audioPlaybackBlocked` сохраняет соединение и показывает Enable sound;
332
+ нажатие вызывает `resumeAudio()`. Повторный запрет — `AUDIO_PLAYBACK_BLOCKED`,
333
+ без перепубликации микрофона. Запрет микрофона по-прежнему срывает старт.
334
+ `stopCall()` завершает звонок, mute — нет.
335
+
336
+ Проверки: `npm test`, `npm run lint`, `npm run format:check`, `npm run build`;
337
+ браузеры — `npx playwright install chromium webkit && npm run test:browser`.
338
+ Размеры сборки и оставшиеся проверки — `VALIDATION.md`.
package/dist/errors.d.ts CHANGED
@@ -16,3 +16,4 @@ export declare function connectErrorCode(error: unknown): VoiceErrorCode;
16
16
  /** MediaDeviceFailure.getFailure — классификатор ошибок getUserMedia из
17
17
  * самого livekit-client (не переизобретаем разбор DOMException.name). */
18
18
  export declare function microphoneErrorCode(error: unknown): VoiceErrorCode;
19
+ export declare function microphoneToggleErrorCode(error: unknown): VoiceErrorCode;
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { VoiceClient } from './voice-client';
2
2
  export { VoiceCallError } from './errors';
3
- export type { AgentState, AgentStateChangedEvent, CallEndedEvent, CallEndedReason, CallStartedEvent, ConnectionState, ConnectionStateChangedEvent, LogLevel, Logger, StartCallOptions, StartCallParams, TranscriptEvent, VoiceClientEventHandler, VoiceClientEventMap, VoiceClientEventName, VoiceClientOptions, VoiceError, VoiceErrorCode, } from './types';
3
+ export type { AgentState, AgentVideoChangedEvent, AgentStateChangedEvent, CallEndedEvent, CallEndedReason, CallStartedEvent, ConnectionState, ConnectionStateChangedEvent, LogLevel, Logger, StartCallOptions, StartCallParams, TranscriptEvent, VoiceClientEventHandler, VoiceClientEventMap, VoiceClientEventName, VoiceClientOptions, VoiceError, VoiceErrorCode, } from './types';