@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 +509 -0
- package/dist/audio.d.ts +16 -0
- package/dist/bundle-entry.d.ts +1 -0
- package/dist/emitter.d.ts +10 -0
- package/dist/errors.d.ts +18 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +400 -0
- package/dist/logger.d.ts +6 -0
- package/dist/types.d.ts +86 -0
- package/dist/utils.d.ts +15 -0
- package/dist/voice-client.bundle.js +39 -0
- package/dist/voice-client.d.ts +84 -0
- package/package.json +40 -0
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.
|
package/dist/audio.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -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;
|
package/dist/index.d.ts
ADDED
|
@@ -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';
|