@hubtalk-ai/web-sdk 0.2.0

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.md ADDED
@@ -0,0 +1,509 @@
1
+ # @hubtalk-ai/web-sdk
2
+
3
+ Browser SDK for running a WebRTC voice call with a HubTalk AI Conversation
4
+ Flow agent from your own website. It is a thin wrapper over
5
+ [`livekit-client`](https://github.com/livekit/client-sdk-js) and the
6
+ platform's existing voice path (`POST /v1/calls/web`): the SDK has no
7
+ transport of its own, holds no API key, and never calls the platform's HTTP
8
+ API — see §9 “Web calls” of the partner integration guide
9
+ (`docs/integration-guide.md` in the platform repository) for the
10
+ backend → frontend → SDK sequence.
11
+
12
+ ## Installation
13
+
14
+ With a bundler:
15
+
16
+ ```bash
17
+ npm install @hubtalk-ai/web-sdk
18
+ ```
19
+
20
+ ```ts
21
+ import { VoiceClient } from '@hubtalk-ai/web-sdk';
22
+ ```
23
+
24
+ Without a build step, load the self-contained bundle (carries its own copy
25
+ of `livekit-client`):
26
+
27
+ ```html
28
+ <script src="voice-client.bundle.js"></script>
29
+ <script>
30
+ const client = new VoiceClient();
31
+ </script>
32
+ ```
33
+
34
+ ## Quickstart
35
+
36
+ ```ts
37
+ import { VoiceClient } from '@hubtalk-ai/web-sdk';
38
+
39
+ const client = new VoiceClient();
40
+
41
+ startCallButton.addEventListener('click', async () => {
42
+ // session = whatever your backend returned from POST /v1/calls/web
43
+ const session = await fetch('/api/start-voice-call', { method: 'POST' }).then((r) => r.json());
44
+ await client.startCall(session);
45
+ });
46
+
47
+ client.on('connectionStateChanged', ({ state }) => console.log('connection:', state));
48
+ client.on('agentSpeakingStarted', () => console.log('agent speaking'));
49
+ client.on('callEnded', ({ reason }) => console.log('call ended:', reason));
50
+
51
+ endCallButton.addEventListener('click', () => client.stopCall());
52
+ ```
53
+
54
+ **`startCall` must run inside a user gesture handler** (a click, a tap) — the
55
+ browser's autoplay policy requires it for the agent's audio to play without
56
+ being blocked, and getting microphone permission works the same way.
57
+
58
+ ## API
59
+
60
+ ### `new VoiceClient(options?)`
61
+
62
+ | Option | Type | Default | Description |
63
+ |---|---|---|---|
64
+ | `audioContainer` | `HTMLElement` | a hidden `<div>` the SDK creates | Where agent `<audio>` elements are appended. |
65
+ | `logger` | `{ error, debug }` | `console` | Custom log sink; the SDK still masks tokens before calling it. |
66
+ | `logLevel` | `'silent' \| 'error' \| 'debug'` | `'error'` | Verbose logging is opt-in. |
67
+ | `connectTimeoutMs` | `number` | livekit-client default (15s) | Peer connection / WebSocket connect timeout. |
68
+
69
+ ### Methods
70
+
71
+ | Method | Behavior |
72
+ |---|---|
73
+ | `startCall({ accessToken, url }, options?)` | Connects and publishes the microphone. Accepts either `{accessToken, url}` or the raw `POST /v1/calls/web` response body (`{access_token, url, …}`) — extra fields are ignored. Resolves once the microphone is published. A second call while a call is active rejects with `ALREADY_IN_CALL`. `options.audio: false` starts a chat-only call — see below. |
74
+ | `stopCall()` | Ends the call. Idempotent — resolves without an `error` event if no call is active. Cancels an in-flight `startCall` if called during `connecting`. |
75
+ | `getState()` | Connection axis: `idle \| connecting \| connected \| reconnecting \| disconnected \| error`. |
76
+ | `getAgentState()` | Agent-activity axis: `unknown \| initializing \| listening \| thinking \| speaking`. Independent of the connection axis. |
77
+ | `sendText(text)` | Sends a typed reply into the conversation (topic `lk.chat`). The agent interrupts its speech and answers it as it would answer speech. Rejects with `NOT_IN_CALL` outside a call; an empty or whitespace-only string is a silent no-op; anything longer than 1000 characters is truncated. Your own message is **not** inserted into the chat by the SDK — it comes back as a `transcript` event with `role: 'user'`. |
78
+ | `getCallId()` | The call's room name (same value as `call_id` in `GET /v1/calls/{call_id}`), or `null` before connecting. |
79
+ | `on(event, handler)` / `off(event, handler)` / `once(event, handler)` | Subscribe / unsubscribe / subscribe-once. |
80
+ | `destroy()` | Stops the call, removes every subscription, releases all resources. The instance is not usable afterward. |
81
+
82
+ ### Events
83
+
84
+ | Event | Payload |
85
+ |---|---|
86
+ | `callStarted` | `{ callId }` |
87
+ | `callEnded` | `{ callId, reason }` — `reason`: `local` (`stopCall`), `remote` (the agent/flow ended it), `connection_lost`, `error` |
88
+ | `connectionStateChanged` | `{ state, previous }` |
89
+ | `agentStateChanged` | `{ state, previous }` |
90
+ | `agentSpeakingStarted` / `agentSpeakingStopped` | `{}` |
91
+ | `transcript` | `{ role, text, final, id }` — `role`: `user` \| `agent`; `text` is the reply so far; `final: false` while it is still growing; `id` is the segment id — deltas of one reply share it, so render one bubble per `id`. Agent text arrives as it is **spoken**, so an interrupted reply never shows its unspoken tail. |
92
+ | `error` | `VoiceError` — see below |
93
+
94
+ ### Chat-only calls
95
+
96
+ `startCall(session, { audio: false })` runs the same call without audio: no
97
+ microphone is published, and the agent's audio is subscribed but never played,
98
+ so the browser never asks for microphone permission and nothing is heard. Text
99
+ keeps working in both directions — `sendText()` and the `transcript` event —
100
+ which makes it the mode for a visitor who cannot or will not talk.
101
+
102
+ ```ts
103
+ await client.startCall(session, { audio: false });
104
+ ```
105
+
106
+ Two consequences of that mode, both worth knowing before you ship it:
107
+
108
+ - Neither `MICROPHONE_PERMISSION_DENIED` nor `MICROPHONE_UNAVAILABLE` can
109
+ occur, and `WEBRTC_UNSUPPORTED` no longer requires `getUserMedia` — only a
110
+ secure context and `RTCPeerConnection`, which LiveKit needs for the data
111
+ channel either way.
112
+ - The agent's audio is **subscribed and discarded**, not left unsubscribed.
113
+ That is not waste for its own sake: in `livekit-agents`, the agent's audio
114
+ output blocks on its track being subscribed
115
+ (`_ParticipantAudioOutput.capture_frame` awaits a future that only
116
+ `publication.wait_for_subscription()` resolves), and the agent's transcript
117
+ is routed through a synchronizer bound to that same output. With no
118
+ subscriber the first frame of speech blocks forever and the agent's text
119
+ never arrives at all — while the echo of the visitor's own typed replies
120
+ still does, because that goes out on a separate, unsynchronized path.
121
+ - The agent therefore still synthesizes speech nobody hears, and LiveKit paces
122
+ the transcript to that playback. So the text arrives at speaking speed
123
+ rather than as fast as the model produces it, and the call costs the same in
124
+ TTS as a voice call. Removing both is a platform-side change — the agent
125
+ needs `RoomOutputOptions(audio_enabled=False)` — not an SDK one.
126
+
127
+ ### Error codes
128
+
129
+ Every failure is a structured `VoiceError`: `{ code, message, state, cause? }`
130
+ — `cause` is the original browser/`livekit-client` error, for your own logs.
131
+
132
+ | Code | When | Resulting state |
133
+ |---|---|---|
134
+ | `INVALID_PARAMS` | `accessToken` or `url` missing/empty | `error` |
135
+ | `WEBRTC_UNSUPPORTED` | No WebRTC support, or not a secure context (HTTPS) | `error` |
136
+ | `MICROPHONE_PERMISSION_DENIED` | Microphone access denied (voice calls only) | `error` |
137
+ | `MICROPHONE_UNAVAILABLE` | No usable microphone device (voice calls only) | `error` |
138
+ | `TOKEN_EXPIRED` | Token's `exp` already passed (checked locally before connecting) | `error` |
139
+ | `TOKEN_INVALID` | Server rejected the token | `error` |
140
+ | `CONNECTION_FAILED` | Could not establish the connection | `error` |
141
+ | `CONNECTION_LOST` | Reconnect attempts exhausted after a successful connection | `disconnected` |
142
+ | `ALREADY_IN_CALL` | `startCall` called while a call is already active | unchanged |
143
+ | `NOT_IN_CALL` | `sendText` called outside an active call | unchanged |
144
+ | `SERVER_ERROR` | Any other signalling/service failure | `error` |
145
+
146
+ ## Browser matrix
147
+
148
+ Bounded by [`livekit-client`](https://github.com/livekit/client-sdk-js)'s own
149
+ WebRTC support matrix. **Not yet verified against a live deployment** —
150
+ tracked as an open pre-check of the source requirement (`docs/patches/patch132-web-voice-sdk.md`
151
+ §7.3); this table is filled in once that manual pass runs, before the first
152
+ release.
153
+
154
+ | Browser | Verified |
155
+ |---|---|
156
+ | Chrome (desktop) | — |
157
+ | Firefox (desktop) | — |
158
+ | Safari (desktop) | — |
159
+ | Chrome (Android) | — |
160
+ | Safari (iOS) | — |
161
+
162
+ iOS Safari needs special attention: both microphone publish and
163
+ `startAudio()` must run strictly inside the user gesture, no exceptions.
164
+
165
+ ## Share links
166
+
167
+ You do not always need a backend of your own. The platform's cabinet can
168
+ issue a temporary link to one published agent — an expiry, a cap on calls
169
+ and one-click revocation — and the page behind that link mints the session
170
+ itself. Whoever opens it talks to the agent with no account, no API key and
171
+ no setup.
172
+
173
+ That page is part of the platform, and it drives the call through this SDK
174
+ without a single change to it: the response it mints has the same shape as
175
+ `POST /v1/calls/web`, so `startCall(session)` takes it as it comes. Nothing
176
+ in the SDK knows about links, and nothing here holds the link token — the
177
+ same rule as for the API key.
178
+
179
+ Use a share link when someone just needs to hear the agent. Use your own
180
+ backend, as in `example/server.py`, when the call has to start from your
181
+ site, under your own domain, with variables your system knows.
182
+
183
+ ## What this SDK does not do (phase 1)
184
+
185
+ - No chat or call-button UI — you build the interface, the SDK reports state
186
+ and takes text. `example/index.html` shows the wiring, it is not a
187
+ shipped component. The reference widget of the example pages is not
188
+ shipped either: copy it, do not import it.
189
+ - No link handling — a share link is opened by the platform's own page, and
190
+ `startCall` takes the session that page already minted. There is no
191
+ `startCall({ demoToken })` and the SDK never presents a secret.
192
+ - No transcript history — the SDK holds no list of replies; whoever renders
193
+ them keeps them.
194
+ - No dynamic variables of its own — they are set once, by your backend, in
195
+ the `POST /v1/calls/web` body that mints the session. By the time the SDK
196
+ has that session the variables are fixed, and there is no API to read or
197
+ change them. `example/server.py` shows the backend side.
198
+ - No `cf.voice`: node names and cascade transitions stay internal. Post-call
199
+ data comes from `GET /v1/calls/{call_id}` or a
200
+ `call_ended`/`call_analyzed` webhook.
201
+ - No rate limit on `sendText` — every message is an LLM turn and TTS
202
+ synthesis, so a page that sends in a loop spends the workspace's money.
203
+ - No token refresh — a call cannot outlive the token's TTL
204
+ (`expires_in` in the `POST /v1/calls/web` response).
205
+ - No video, DTMF, custom TURN/ICE, or client-side audio processing.
206
+
207
+ ## Development
208
+
209
+ ```bash
210
+ npm install
211
+ npm run lint
212
+ npm run format:check
213
+ npm run build # dist/index.js (ESM) + dist/voice-client.bundle.js (script tag) + dist/*.d.ts
214
+
215
+ python3 example/server.py --selftest # the demo backend's field/merge logic
216
+ ```
217
+
218
+ `--selftest` is the check that guards the parts of `example/server.py` with
219
+ branches in them: `@today` and keyless fields in `demo_fields`, the
220
+ allowlist in `merge_variables`, and every shipped scenario file parsing into
221
+ a renderable form. It starts no server and needs no `.env`.
222
+
223
+ ### Running the example pages
224
+
225
+ `example/server.py` is the backend half of the example: it holds the API key,
226
+ mints a room through `POST /v1/calls/web` and serves the pages. Standard
227
+ library only, no dependencies. Every call parameter comes from `.env` — copy
228
+ `.env.example` and fill it in:
229
+
230
+ ```
231
+ CFLOW_API_URL=http://localhost:8100
232
+ CFLOW_API_KEY=cfk_…
233
+ CFLOW_AGENT_ID=my_agent_v1 # fallback when the page sends no agent_id
234
+ # optional JSON objects, passed straight to POST /v1/calls/web
235
+ # CFLOW_DYNAMIC_VARIABLES={"name": "Aigerim"}
236
+ # CFLOW_METADATA={"source": "example"}
237
+ # optional: which scenario to open with, and per-half overrides of it
238
+ # CFLOW_DEMO_SCENARIO=missed-deposit
239
+ # CFLOW_DEMO_FIELDS=[{"key":"rl_clientName","label":"Client name","default":"John"}]
240
+ # CFLOW_DEMO_HERO={"headline":"Missed deposit follow-up"}
241
+ ```
242
+
243
+ **One file per demo scenario.** `example/scenarios/<id>.json` declares the
244
+ dynamic variables the pages let a visitor edit — the ones the agent may
245
+ reference as `{{key}}` — plus the copy of the call page's left column. The id
246
+ is the file name. Two ship filled in:
247
+
248
+ | id | Shows |
249
+ |---|---|
250
+ | `missed-deposit` | a client whose scheduled deposit was returned; 14 variables |
251
+ | `identity-verification` | verifying the client, then their enrolled creditor accounts; 21 variables |
252
+
253
+ **Exactly one is active, and the server picks it** — the one whose file says
254
+ `"default": true`, or `CFLOW_DEMO_SCENARIO`. That is not a missing feature:
255
+ these variables are the variables of the agent in `CFLOW_AGENT_ID`, and one
256
+ agent is configured at a time, so a page-level switch could only ever produce
257
+ variables the running agent never references. The scenario travels with the
258
+ agent, in `.env`. Relying on file order for the default would let a new file
259
+ quietly replace what a live demo shows, hence the explicit marker.
260
+
261
+ A new demo is a new file plus one `.env` line — no HTML.
262
+
263
+ Per field: `key`, `label`, and optionally `default` (`"@today"` resolves to
264
+ today's date on the server), `placeholder`, `type`, `readonly`, `half` (two
265
+ fields share a row).
266
+
267
+ The server serves the active scenario from `GET /api/config`, `/` and `/call`
268
+ render it (`/free` declares its own), and `POST /api/session` accepts values
269
+ **only** for that scenario's keys — a key belonging to another scenario is
270
+ dropped like an invented one, and everything else in
271
+ `CFLOW_DYNAMIC_VARIABLES` stays server-side. That
272
+ filter is not demo scaffolding: a production backend owes the same guarantee,
273
+ since the browser names values and never the contract.
274
+
275
+ **Three pages, one widget.** `/` is the widget on a host page, `/call` the
276
+ call as the whole page, `/free` the widget with nothing configured — the
277
+ agent name and every dynamic variable are typed into the form, `+ Variable`
278
+ adds a key/value row.
279
+
280
+ The allowlist follows the agent, not the page: a request for the agent in
281
+ `CFLOW_AGENT_ID`, on `.env`'s own key, is filtered against the active
282
+ scenario's keys, and a request for any other agent — or on a key the page
283
+ brought — has its keys sent through unfiltered. That is what makes `/free`
284
+ work — it brings its own agent, whose contract the active scenario does not
285
+ describe — and it is why naming a different agent on `/` or `/call` drops the
286
+ filter too.
287
+
288
+ **Which agent is called.** The page names it: `const AGENT_ID = '…'` in the
289
+ widget options of `example/index.html` and `example/call-details.html`, sent
290
+ as `agent_id` in `POST /api/session`. It is the agent's **name**, the same
291
+ string `CFLOW_AGENT_ID` holds — not a uuid. Left empty — the shipped
292
+ default — the form grows an Agent name input and the visitor names it per
293
+ call. That input rides the same form as the variables but is never one of
294
+ them: it carries the reserved key `__agent_id` (`HtDemo.AGENT_KEY`), which
295
+ the wiring strips out before posting. The server falls back to
296
+ `CFLOW_AGENT_ID` only when the request names no agent, and answers 503 when
297
+ neither does.
298
+
299
+ **Which organisation the call runs as.** The key decides, and `.env`'s
300
+ `CFLOW_API_KEY` is again only the fallback: `/free` carries an **Organisation
301
+ API key** field in the middle of the page — not in the widget, since the key
302
+ is not a call parameter — and a key typed there is sent as `api_key` and used
303
+ for that call instead. One deployment can therefore demo any organisation
304
+ without an `.env` edit and a restart. No organisation id goes with it:
305
+ `POST /v1/calls/web` authenticates on the bearer key alone. The field is a
306
+ demo affordance and not a template — a production backend holds its key
307
+ server-side and never lets the browser name one — and the server's own key
308
+ still never travels to a page.
309
+
310
+ ```bash
311
+ npm run build # the page loads ../dist/voice-client.bundle.js
312
+ python3 example/server.py # http://localhost:8000/, /call and /free
313
+ ```
314
+
315
+ Real environment variables win over `.env`, so `CFLOW_AGENT_ID=other python3
316
+ example/server.py` overrides one parameter for a single run.
317
+
318
+ Every press of Call asks the server for a fresh room. That is what keeps the
319
+ short-lived LiveKit `access_token` fresh: a page left open for longer than the
320
+ token TTL still starts a working call. The browser never talks to `/v1`
321
+ directly, so no `CFLOW_CORS_ORIGINS` entry is needed — and the API key never
322
+ reaches the page. That is the shape a production integration has too, with
323
+ your own backend in place of `server.py`; `startCall()` accepts the
324
+ `POST /v1/calls/web` response as-is.
325
+
326
+ The example server exposes only the three example pages, their stylesheet
327
+ (`example/widget.css`), their shared wiring (`example/demo-call.js`), the
328
+ bundle and the two `/api/*` endpoints (the scenario files are read on the
329
+ server, never served); it never serves the project directory
330
+ or `.env`. Every page offers both a voice call and a chat-only one:
331
+
332
+ | Page | What it shows |
333
+ |---|---|
334
+ | `/` | a demo site with the call widget pinned in the corner — the call starts from the launcher, and “Call details” inside the panel holds the dynamic variables |
335
+ | `/call` | the call as the whole page: a branded column plus the same form, switching to the conversation once the call starts |
336
+ | `/free` | the corner widget with nothing configured — agent name and variables are typed in, `+ Variable` adds a row; the organisation's API key is a field in the page itself |
337
+
338
+ All three are clean URLs — no `.html`, since these links get pasted to
339
+ clients. The old paths (`/index.html`, `/call-details.html`,
340
+ `/free-form.html`, anything under `/example/`) answer `301` to the short
341
+ form, so a link already shared keeps working.
342
+
343
+ On iOS Safari, note that the network round trip between the click and
344
+ `startCall()` can consume the user gesture that audio playback requires. A
345
+ widget that has to support iOS should fetch the session before the click and
346
+ hand it straight to `startCall()`.
347
+
348
+ ### Embedding the widget
349
+
350
+ The SDK is headless: it ships no markup, no CSS and no themes. Your page owns
351
+ every pixel. `example/index.html` together with `example/widget.css` is a
352
+ reference you copy into your project and edit — not a component the package
353
+ delivers.
354
+
355
+ **Markup.** The widget is a launcher plus a panel, both inside one root
356
+ element that carries the tokens:
357
+
358
+ ```html
359
+ <div class="ht-widget">
360
+ <button class="ht-launcher" id="launcher">…</button>
361
+ <section class="ht-panel" id="panel" hidden>
362
+ <header class="ht-head">
363
+ <span class="ht-title">Voice agent</span>
364
+ <span class="ht-pill" id="pill">Ready</span>
365
+ <span class="ht-timer" id="timer" hidden>0:00</span>
366
+ <button class="ht-icon" id="collapse">…</button>
367
+ </header>
368
+ <details class="ht-details" id="details" open>
369
+ <summary>Call details</summary>
370
+ <div class="ht-fields" id="fields"></div>
371
+ </details>
372
+ <div class="ht-log" id="log"></div>
373
+ <div class="ht-debug" id="state"></div>
374
+ <div class="ht-composer">
375
+ <input id="message" disabled />
376
+ <button class="ht-icon" id="send" disabled>…</button>
377
+ </div>
378
+ <div class="ht-actions">
379
+ <button class="ht-btn" id="call">Call</button>
380
+ <button class="ht-btn ht-btn-ghost" id="chat">Chat</button>
381
+ <button class="ht-btn ht-btn-danger" id="hangup" disabled>Hang up</button>
382
+ </div>
383
+ </section>
384
+ </div>
385
+ ```
386
+
387
+ The `<details>` block is where the dynamic variables are typed; the widget
388
+ hides it for the duration of a call. `Chat` is the same call with
389
+ `{ audio: false }` — see “Chat-only calls” above.
390
+
391
+ **The wiring is shared, not duplicated.** `example/demo-call.js` holds the
392
+ part that is identical on all three example pages — transcript bubbles, the
393
+ timer, the composer, session creation, `startCall` — and each page passes its
394
+ own nodes and its own layout hooks:
395
+
396
+ ```js
397
+ HtDemo.mountCall({
398
+ client: new VoiceClient(),
399
+ agentId: AGENT_ID, // '' → the form asks the visitor for an Agent name
400
+ refs: { pill, timer, state, log, empty, message, send, call, chat, hangup, liveDot },
401
+ readVariables: () => HtDemo.readValues(fieldsEl),
402
+ onCallStarted: () => { /* your layout switch */ },
403
+ onCallEnded: (reason) => { /* and back */ },
404
+ });
405
+ ```
406
+
407
+ Every `refs` node is optional — a page passes what its markup has, and the
408
+ wiring skips the rest. `mountCall` returns `{ startVoice, startChatOnly,
409
+ stop, render }` for a page that wants to drive the call from something other
410
+ than the `call` / `chat` / `hangup` buttons.
411
+
412
+ The rest of `HtDemo` is the form half, which is why no page repeats it:
413
+
414
+ | Helper | Does |
415
+ |---|---|
416
+ | `loadConfig()` | `GET /api/config` — the active scenario's `fields` and `hero` |
417
+ | `renderFields(container, fields, agentId)` | the variable inputs, preceded by an Agent name input when `agentId` is empty |
418
+ | `readValues(container)` | what the visitor typed, keyed by dynamic variable, read off the DOM |
419
+ | `applyHero(root, hero)` | fills every `[data-hero="<key>"]` node, keeping the markup's own copy as the fallback |
420
+ | `createSession(variables, agentId)` | `POST /api/session` |
421
+
422
+ **Wiring.** Which SDK event drives which node:
423
+
424
+ | Event | Node |
425
+ |---|---|
426
+ | `connectionStateChanged`, `agentStateChanged` | the state pill and the raw debug line |
427
+ | `transcript` | one bubble per segment `id`; `final: false` renders as interim |
428
+ | `callStarted` | clears the log, starts the timer, enables the composer and Hang up |
429
+ | `callEnded` | stops the timer, disables the composer, appends why it ended |
430
+ | `error` | stops the timer, hides the live dot, appends the code plus its English line to the log |
431
+
432
+ Collapsing the panel is presentation only — never call `stopCall()` from it,
433
+ or a visitor loses the conversation by tidying the widget away.
434
+
435
+ **Restyling.** Override the tokens on `.ht-widget`; nothing else needs
436
+ touching:
437
+
438
+ | Token | What it paints |
439
+ |---|---|
440
+ | `--accent`, `--accent-contrast` | the launcher, the Call button, the visitor's bubbles |
441
+ | `--bg-panel`, `--bg-elev` | the panel surface and the agent's bubbles |
442
+ | `--text`, `--text-dim` | primary and secondary text |
443
+ | `--border` | every hairline |
444
+ | `--radius`, `--radius-sm` | corner softness |
445
+ | `--hero-bg`, `--hero-text` | the branded column of the call page (deliberately the same in both schemes — a brand block does not invert) |
446
+
447
+ ```css
448
+ .ht-widget {
449
+ --accent: #1f6feb;
450
+ --accent-contrast: #ffffff;
451
+ --radius: 8px;
452
+ }
453
+ ```
454
+
455
+ The file ships both palettes and follows `prefers-color-scheme`; override a
456
+ token inside your own media query to diverge per scheme.
457
+
458
+ **Boundaries.** The tokens are declared on the `.ht-demo` / `.ht-call` /
459
+ `.ht-widget` roots rather than on `:root`, and every class carries the `ht-`
460
+ prefix — loading the file cannot repaint your site. Copy the `.ht-widget`
461
+ block plus the shared form primitives (`.ht-details`, `.ht-fields`,
462
+ `.ht-field*`, `.ht-btn*`) and leave the `.ht-demo` / `.ht-call` page sections
463
+ behind. The package does not serve the stylesheet: copy it into your own
464
+ build.
465
+
466
+ ### Deploying the example
467
+
468
+ The deployment script builds the SDK and runs the example server on the same
469
+ machine. Put the required API configuration in `.env` at the project root.
470
+
471
+ ```bash
472
+ scripts/deploy-example.sh
473
+ ```
474
+
475
+ Set `PORT` to choose a port (default `8000`). For public access, put this
476
+ server behind an HTTPS reverse proxy, since browsers require a secure context
477
+ for microphone and WebRTC access.
478
+
479
+ ### Running with systemd
480
+
481
+ The included unit runs the example after the network is available and restarts
482
+ it after a failure. It ships configured for the demo box — user
483
+ `cybernet-adm`, checkout at `/home/cybernet-adm/hubtalk-web-sdk` — so edit
484
+ `User` and `WorkingDirectory` before installing it anywhere else. It passes
485
+ no `EnvironmentFile`: the server reads `.env` from the checkout itself.
486
+
487
+ ```bash
488
+ sudo cp example/hubtalk-webrtc-example.service /etc/systemd/system/
489
+ sudo systemctl daemon-reload
490
+ sudo systemctl enable --now hubtalk-webrtc-example
491
+ sudo systemctl status hubtalk-webrtc-example
492
+ ```
493
+
494
+ **`dist/` is gitignored, so `git pull` never updates the bundle the browser
495
+ loads.** A pull plus a restart keeps serving the SDK that was last built on
496
+ that machine — the page works, it is just the previous code, which is a very
497
+ quiet way to lose an afternoon. Always build:
498
+
499
+ ```bash
500
+ npm run build
501
+ sudo systemctl restart hubtalk-webrtc-example
502
+ ```
503
+
504
+ Two guards make that hard to forget: the unit re-copied from
505
+ `example/hubtalk-webrtc-example.service` rebuilds on every restart
506
+ (`ExecStartPre`), and the server prints a loud warning at startup when
507
+ `dist/voice-client.bundle.js` is older than `src/`. The pages and the bundle
508
+ are served `Cache-Control: no-store`, so a plain reload is enough — no
509
+ Ctrl+F5.
@@ -0,0 +1,16 @@
1
+ import type { RemoteTrack } from 'livekit-client';
2
+ /**
3
+ * По одному <audio>-элементу на трек (§2.3 ТЗ №132): общий элемент
4
+ * перезаписывает srcObject — фоновый звук глушил голос агента
5
+ * (VoiceCallPanel.tsx:442-446). track.attach()/detach() уже создают/
6
+ * возвращают элемент на трек — здесь только контейнер и уборка.
7
+ */
8
+ export declare class AudioElements {
9
+ readonly element: HTMLElement;
10
+ private readonly owned;
11
+ constructor(container?: HTMLElement);
12
+ attach(track: RemoteTrack): void;
13
+ detach(track: RemoteTrack): void;
14
+ clear(): void;
15
+ destroy(): void;
16
+ }
@@ -0,0 +1 @@
1
+ export { VoiceClient as default } from './voice-client';
@@ -0,0 +1,10 @@
1
+ import type { VoiceClientEventHandler, VoiceClientEventMap, VoiceClientEventName } from './types';
2
+ /** Малый типизированный emitter — своей событийной модели без зависимостей. */
3
+ export declare class Emitter {
4
+ private readonly listeners;
5
+ on<E extends VoiceClientEventName>(event: E, handler: VoiceClientEventHandler<E>): void;
6
+ off<E extends VoiceClientEventName>(event: E, handler: VoiceClientEventHandler<E>): void;
7
+ once<E extends VoiceClientEventName>(event: E, handler: VoiceClientEventHandler<E>): void;
8
+ emit<E extends VoiceClientEventName>(event: E, payload: VoiceClientEventMap[E]): void;
9
+ clear(): void;
10
+ }
@@ -0,0 +1,18 @@
1
+ import type { ConnectionState, VoiceError, VoiceErrorCode } from './types';
2
+ export declare class VoiceCallError extends Error implements VoiceError {
3
+ readonly code: VoiceErrorCode;
4
+ readonly state: ConnectionState;
5
+ readonly cause?: unknown;
6
+ constructor(code: VoiceErrorCode, state: ConnectionState, cause?: unknown);
7
+ }
8
+ /**
9
+ * Классификация ошибки room.connect() — по факту (livekit-client 2.20.0,
10
+ * ConnectionError/ConnectionErrorReason, см. §7 пред-проверку в ТЗ №132:
11
+ * NotAllowed приходит и на просроченный, и на отклонённый токен — TOKEN_EXPIRED
12
+ * уже отфильтрован локальным пред-чеком до connect, поэтому здесь это
13
+ * TOKEN_INVALID).
14
+ */
15
+ export declare function connectErrorCode(error: unknown): VoiceErrorCode;
16
+ /** MediaDeviceFailure.getFailure — классификатор ошибок getUserMedia из
17
+ * самого livekit-client (не переизобретаем разбор DOMException.name). */
18
+ export declare function microphoneErrorCode(error: unknown): VoiceErrorCode;
@@ -0,0 +1,3 @@
1
+ export { VoiceClient } from './voice-client';
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';