@burdenoff/website-sdk 2026.1028.1 → 2026.1029.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.md +159 -1
- package/dist/avatar-live/index.d.mts +11 -0
- package/dist/avatar-live/index.d.ts +11 -0
- package/dist/avatar-live/index.js +120 -0
- package/dist/avatar-live/index.js.map +1 -0
- package/dist/avatar-live/index.mjs +118 -0
- package/dist/avatar-live/index.mjs.map +1 -0
- package/dist/avatar-live-view/index.d.mts +42 -0
- package/dist/avatar-live-view/index.d.ts +42 -0
- package/dist/avatar-live-view/index.js +1422 -0
- package/dist/avatar-live-view/index.js.map +1 -0
- package/dist/avatar-live-view/index.mjs +1420 -0
- package/dist/avatar-live-view/index.mjs.map +1 -0
- package/dist/components/explore.d.mts +2 -2
- package/dist/components/explore.d.ts +2 -2
- package/dist/components/explore.js +11 -4
- package/dist/components/explore.js.map +1 -1
- package/dist/components/explore.mjs +11 -4
- package/dist/components/explore.mjs.map +1 -1
- package/dist/{explore-BEgq1_0O.d.mts → explore-DUTNYUOm.d.mts} +1 -1
- package/dist/{explore-CHiP9u-4.d.ts → explore-DjQE7Xia.d.ts} +1 -1
- package/dist/explore-avatar-live-config-B171o1Yo.d.ts +283 -0
- package/dist/explore-avatar-live-config-BGsGwbcq.d.mts +283 -0
- package/dist/{explore-types-_9UJu6EA.d.mts → explore-types-Bb2Ih3d8.d.mts} +1 -1
- package/dist/{explore-types-_9UJu6EA.d.ts → explore-types-Bb2Ih3d8.d.ts} +1 -1
- package/dist/hooks/index.d.mts +3 -2
- package/dist/hooks/index.d.ts +3 -2
- package/dist/hooks/index.js +6 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/index.mjs +6 -1
- package/dist/hooks/index.mjs.map +1 -1
- package/dist/index.d.mts +16 -253
- package/dist/index.d.ts +16 -253
- package/dist/index.js +214 -36
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +214 -36
- package/dist/index.mjs.map +1 -1
- package/dist/types-CndbmBK-.d.mts +68 -0
- package/dist/types-CndbmBK-.d.ts +68 -0
- package/dist/{use-progressive-reveal-C0hkdu_i.d.ts → use-explore-chat-DunjG-3p.d.ts} +10 -36
- package/dist/{use-progressive-reveal-BFPWbEXy.d.mts → use-explore-chat-zRuoAin6.d.mts} +10 -36
- package/dist/use-progressive-reveal-ppicEgzZ.d.mts +35 -0
- package/dist/use-progressive-reveal-ppicEgzZ.d.ts +35 -0
- package/package.json +15 -3
package/README.md
CHANGED
|
@@ -243,6 +243,162 @@ a product-scoped panel still share one conversation. Without a `productSlug`,
|
|
|
243
243
|
development build logs a warning). The default, `scope="portfolio"`, renders
|
|
244
244
|
exactly what it always has.
|
|
245
245
|
|
|
246
|
+
#### Live avatar (opt-in, receive-only)
|
|
247
|
+
|
|
248
|
+
The site-wide `ExplorePanel`'s Avatar view shows the avatar's picture, "Live
|
|
249
|
+
avatar coming soon" and "Continue in Chat". A site can opt in to a live avatar
|
|
250
|
+
there instead: an AI avatar that reads the conversation's first answer aloud,
|
|
251
|
+
which the visitor watches and listens to. Their browser only receives: it never
|
|
252
|
+
publishes audio, video or data, and it sends the avatar nothing to say.
|
|
253
|
+
|
|
254
|
+
```tsx
|
|
255
|
+
<ExploreChatProvider enabled={started} productSlug="healthybowl" shareToken={shareToken}>
|
|
256
|
+
<ExplorePanel
|
|
257
|
+
open={open}
|
|
258
|
+
onOpenChange={setOpen}
|
|
259
|
+
productSlug="healthybowl"
|
|
260
|
+
scope="product"
|
|
261
|
+
examplePrompts={suggestedQuestions}
|
|
262
|
+
avatar={{
|
|
263
|
+
src: '/avatar.webp',
|
|
264
|
+
live: {
|
|
265
|
+
site: 'healthybowl',
|
|
266
|
+
load: () => import('@burdenoff/website-sdk/avatar-live'),
|
|
267
|
+
turnstileSiteKey: TURNSTILE_SITE_KEY,
|
|
268
|
+
manifestVersion: 'hb-en-1', // the version of `suggestedQuestions`
|
|
269
|
+
},
|
|
270
|
+
}}
|
|
271
|
+
/>
|
|
272
|
+
</ExploreChatProvider>
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
- **Opt-in, and inert without it.** Without `avatar.live` the panel renders
|
|
276
|
+
and behaves exactly as before and asks no server anything. With it, nothing
|
|
277
|
+
happens on page load: the first request is made when the visitor opens the
|
|
278
|
+
Avatar view.
|
|
279
|
+
- **A chunk of its own, so a site that does not use it does not carry it.** The
|
|
280
|
+
live avatar's view, its state machine and its words in six locales are a
|
|
281
|
+
separate file in the package (`dist/avatar-live-view/`, not a public entry:
|
|
282
|
+
it is in no `exports`), which the panel loads with a dynamic `import()` the
|
|
283
|
+
first time the Avatar view opens on a site that passes `avatar.live`. The
|
|
284
|
+
root entry carries only the few lines that decide whether it is wanted and
|
|
285
|
+
what the Avatar view shows until it arrives (the same picture, and the same
|
|
286
|
+
bytes in a server render or a prerendered page, as before). A site that never
|
|
287
|
+
passes `avatar.live` never downloads it: its bundle grows by under a kilobyte
|
|
288
|
+
gzipped, instead of the roughly twelve the view weighs. Your bundler needs
|
|
289
|
+
to split dynamic imports, as Vite, webpack and Rollup do. If the file cannot
|
|
290
|
+
be fetched (offline, or a deploy has replaced it), or has not arrived after
|
|
291
|
+
eight seconds, the Avatar view shows what it showed before the live avatar,
|
|
292
|
+
also while it asks again, which it does each time the view opens. Whether
|
|
293
|
+
that fetches the file again is the browser's call: Safari and Firefox fetch a
|
|
294
|
+
module that failed once again, but Chromium-based browsers (Chrome, Edge,
|
|
295
|
+
Chrome on Android) keep the failed fetch for as long as the page lives, so
|
|
296
|
+
there the Avatar view stays as it was until the page is reloaded. A file that
|
|
297
|
+
arrives after the eight seconds is used if the visitor is still on the Avatar
|
|
298
|
+
view. `bun run build` checks the layout.
|
|
299
|
+
- **Install `livekit-client` (^2.22.3) on that site only.** It is an optional
|
|
300
|
+
peer dependency of this package, used by the separate
|
|
301
|
+
`@burdenoff/website-sdk/avatar-live` entry and nowhere else. Pass that entry
|
|
302
|
+
as `live.load`, as above, so your bundler splits it into its own chunk; the
|
|
303
|
+
panel calls it only after the visitor presses Start. The package's own build
|
|
304
|
+
checks that no other entry refers to the library.
|
|
305
|
+
- **Needs the panel under an `ExploreChatProvider`.** The live avatar reads the
|
|
306
|
+
provider's conversation. Without a provider, on a shared (read-only)
|
|
307
|
+
conversation, in a locale whose strings have not been reviewed yet (English
|
|
308
|
+
only, for now), or with an incomplete `live`, the Avatar view stays as it was.
|
|
309
|
+
- **One answer: the first, to a suggested question.** The avatar service
|
|
310
|
+
accepts a conversation only when it holds exactly two messages: the first user
|
|
311
|
+
message (a suggested question) and the first assistant answer, completed. The
|
|
312
|
+
view applies the same rule before it offers anything. With no question asked
|
|
313
|
+
yet, or its answer still on its way, it asks the visitor to choose a suggested
|
|
314
|
+
question in Chat first. After a failed first answer, or once the chat has gone
|
|
315
|
+
past that first question and answer (a second question, or a failed answer
|
|
316
|
+
asked again without Retry), it asks the visitor to start a new chat and choose
|
|
317
|
+
a suggested question. Whether the first question really is one of the
|
|
318
|
+
suggested ones is the service's to judge (`not_eligible`).
|
|
319
|
+
- **What the visitor sees.** Opening the Avatar view asks the gateway (the
|
|
320
|
+
origin of your configured `gatewayUrl`, at `/api/web-avatar/v1/availability`)
|
|
321
|
+
whether the live avatar is offered for this site and locale. When it is and
|
|
322
|
+
the conversation has its first answer, a consent card explains what happens
|
|
323
|
+
and links to `/privacy#live-avatar`; with no answer yet, the view asks the
|
|
324
|
+
visitor to choose a suggested question in Chat first. Start runs a
|
|
325
|
+
Cloudflare Turnstile check (action `avatar_start`), creates the session and
|
|
326
|
+
joins its room: the video, the captions, an "AI avatar · not a real person"
|
|
327
|
+
badge, a countdown, Stop and Continue in Chat. Where a browser holds sound
|
|
328
|
+
back (Safari, iOS), a "Tap to turn on sound" button appears. A refusal (busy,
|
|
329
|
+
limits, region, language, paused, a question the avatar cannot read) is one
|
|
330
|
+
short sentence and "Continue in Chat". A network or CORS failure, or a route
|
|
331
|
+
that is not there, shows the view as it was before.
|
|
332
|
+
- **Ending.** Stop, switching to Chat, closing the panel, hiding the page, and
|
|
333
|
+
the live avatar going off under a session (the panel's locale changing to one
|
|
334
|
+
whose strings have not been reviewed) all end the session with a `keepalive`
|
|
335
|
+
request and leave the room. The end is a simple CORS request: its body is a
|
|
336
|
+
string and the SDK sets no `content-type`, so the browser sends it as
|
|
337
|
+
`text/plain;charset=UTF-8` (which the gateway accepts for this route), and on
|
|
338
|
+
production nothing else is added, so no preflight has to complete while the
|
|
339
|
+
page unloads. The server ends sessions on its own deadlines as well.
|
|
340
|
+
- **Consent copy is versioned.** The panel shows a consent card only when it
|
|
341
|
+
holds reviewed copy for exactly the version the server names (the current
|
|
342
|
+
English copy is `web-consent-2026-11`); otherwise the view stays as it was.
|
|
343
|
+
- **Requests.** All three go to the gateway without credentials. The create
|
|
344
|
+
request carries `content-type: application/json`, `idempotency-key` (a UUID
|
|
345
|
+
v4) and `x-web-avatar-bot-token` (the Turnstile token, which the gateway
|
|
346
|
+
verifies and does not forward); the availability request and the end request
|
|
347
|
+
carry no header of the SDK's own. The conversation token is sent in the create
|
|
348
|
+
body only. Neither it, nor the session's end secret, nor the room token, nor
|
|
349
|
+
the bot check's token is ever written into the page, the console or storage by
|
|
350
|
+
the live avatar (the chat keeps its own copy of the conversation in
|
|
351
|
+
`localStorage`, as it always has).
|
|
352
|
+
- **Bot-check binding (web API contract v1.1, additive).** The Turnstile widget
|
|
353
|
+
is rendered with `cData` set to
|
|
354
|
+
`base64url_nopad(SHA-256(UTF-8("boff-web-avatar-cdata/v1\n" + site + "\n" + locale + "\n" + conversationToken + "\n" + messageId)))`,
|
|
355
|
+
made with WebCrypto from the exact values the create body then carries, before
|
|
356
|
+
the widget is rendered, so the gateway can check that the token was minted for
|
|
357
|
+
this request. The action stays `avatar_start`, and nothing else in v1
|
|
358
|
+
changes. A browser without WebCrypto (a page that is not a secure context)
|
|
359
|
+
cannot start the live avatar: Start loads neither the room module nor the bot
|
|
360
|
+
check's script there, and shows the view as it was. Pinned vector: site
|
|
361
|
+
`healthybowl`, locale `en`, conversation token
|
|
362
|
+
`ct_vector_0123456789abcdefghijklmnopqrstuvwx`, message id
|
|
363
|
+
`11111111-2222-4333-8444-555555555555` give
|
|
364
|
+
`si4b6TQ3lxnlbdqG3IzgjbHrnlD8SMo1E0of1c6uZFY`.
|
|
365
|
+
- **Alpha builds.** On the alpha gateway hosts the configured Cloudflare Access
|
|
366
|
+
headers (`cf-access-client-id`, `cf-access-client-secret`) are added to all
|
|
367
|
+
three requests, as on every SDK request, so each of them is a preflighted
|
|
368
|
+
request there. An alpha build that sends them relies on the gateway's web
|
|
369
|
+
preflight allowing exactly those two headers on non-production hosts. A
|
|
370
|
+
production build sets no Access headers, and its end request makes no
|
|
371
|
+
preflight.
|
|
372
|
+
- **Your page's headers.** A site with a Content Security Policy must allow:
|
|
373
|
+
- `script-src` and `frame-src`: `https://challenges.cloudflare.com` (the
|
|
374
|
+
Turnstile bot check);
|
|
375
|
+
- `connect-src`, the gateway origin (the three requests);
|
|
376
|
+
- `connect-src`, the media room: `wss://<project>.livekit.cloud`. For a
|
|
377
|
+
LiveKit Cloud room the media library also asks
|
|
378
|
+
`https://<project>.livekit.cloud/settings/regions` (sending the room token as
|
|
379
|
+
a Bearer header) and, when the first host cannot be reached, fails over to
|
|
380
|
+
the regional hosts and uses the room's `/rtc/validate` check. So allow
|
|
381
|
+
`https://<project>.livekit.cloud` too, and the regional hosts, in practice
|
|
382
|
+
`wss://*.livekit.cloud` and `https://*.livekit.cloud`. Under a strict
|
|
383
|
+
`connect-src` those are only console warnings, but region failover is lost.
|
|
384
|
+
A self-hosted room needs its own `wss://` origin, and the `https://` origin
|
|
385
|
+
of the same host for the validation check.
|
|
386
|
+
- **Not verified in this package.** Every test here uses fakes: nothing reaches
|
|
387
|
+
Turnstile, the gateway, the avatar service or the media room. Left to the
|
|
388
|
+
alpha test: real playback on iPhone Safari and Android Chrome; that
|
|
389
|
+
`page_hide` ends actually arrive from those browsers; and that the receive-only
|
|
390
|
+
token has `canPublish=false` and `canPublishData=false`, and a worker RPC
|
|
391
|
+
addressed to the viewer is dropped without disconnecting it (the media client
|
|
392
|
+
answers incoming RPC requests on its own, so "never publishes" holds only as
|
|
393
|
+
far as the token's permissions do).
|
|
394
|
+
|
|
395
|
+
Testing hooks (`data-boff-explore`): `avatar-live` (with `data-phase`),
|
|
396
|
+
`avatar-live-status`, `avatar-live-consent`, `avatar-live-privacy`,
|
|
397
|
+
`avatar-live-start`, `avatar-live-not-now`, `avatar-live-stop`,
|
|
398
|
+
`avatar-live-video`, `avatar-live-audio`, `avatar-live-badge`,
|
|
399
|
+
`avatar-live-captions`, `avatar-live-countdown`, `avatar-live-sound`,
|
|
400
|
+
`avatar-live-continue`, `avatar-live-back`, `avatar-live-bot-check`.
|
|
401
|
+
|
|
246
402
|
**`ExploreCta` props**: `variant` (`'header' \| 'mobile' \| 'floating'`, required),
|
|
247
403
|
`href` (default `/explore`), `label` (default `Ask AI`), `className`,
|
|
248
404
|
`onNavigate`, `onClick`, `currentPath`. The `floating` variant hides itself on
|
|
@@ -265,7 +421,9 @@ with it. This is separate from the v2 checkbox used by `ContactPage`.
|
|
|
265
421
|
**Headless use.** `useExploreChat(options)` exposes the whole state machine —
|
|
266
422
|
`{ phase, catalog, messages, activity, remainingToday, error, focusProduct,
|
|
267
423
|
setFocusProduct, send, stop, retry, reset, canSend }` — if you want to build a
|
|
268
|
-
different shell around it.
|
|
424
|
+
different shell around it. `getConversationToken()` returns the visitor's
|
|
425
|
+
conversation token at the moment of the call (a function, so it is never
|
|
426
|
+
rendered or serialised with the rest).
|
|
269
427
|
|
|
270
428
|
**Inline concept illustrations.** An answer may embed images from the product
|
|
271
429
|
sites' concept pages (``, followed by the backend's own caption
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { R as ReceiveOnlyJoinOptions, a as ReceiveOnlySession } from '../types-CndbmBK-.mjs';
|
|
2
|
+
export { A as AvatarLiveModule, b as ReceiveOnlyEvent } from '../types-CndbmBK-.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Join the room in `options.url` with the receive-only `options.token`, and play the avatar worker's video and voice
|
|
6
|
+
* in `options.video` and `options.audio`. Resolves once connected; rejects when the room cannot be joined (the panel
|
|
7
|
+
* then ends the session).
|
|
8
|
+
*/
|
|
9
|
+
declare function joinReceiveOnly(options: ReceiveOnlyJoinOptions): Promise<ReceiveOnlySession>;
|
|
10
|
+
|
|
11
|
+
export { ReceiveOnlyJoinOptions, ReceiveOnlySession, joinReceiveOnly };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { R as ReceiveOnlyJoinOptions, a as ReceiveOnlySession } from '../types-CndbmBK-.js';
|
|
2
|
+
export { A as AvatarLiveModule, b as ReceiveOnlyEvent } from '../types-CndbmBK-.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Join the room in `options.url` with the receive-only `options.token`, and play the avatar worker's video and voice
|
|
6
|
+
* in `options.video` and `options.audio`. Resolves once connected; rejects when the room cannot be joined (the panel
|
|
7
|
+
* then ends the session).
|
|
8
|
+
*/
|
|
9
|
+
declare function joinReceiveOnly(options: ReceiveOnlyJoinOptions): Promise<ReceiveOnlySession>;
|
|
10
|
+
|
|
11
|
+
export { ReceiveOnlyJoinOptions, ReceiveOnlySession, joinReceiveOnly };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var livekitClient = require('livekit-client');
|
|
4
|
+
|
|
5
|
+
var CLOSED_BY_SERVER = /* @__PURE__ */ new Set([
|
|
6
|
+
livekitClient.DisconnectReason.ROOM_DELETED,
|
|
7
|
+
livekitClient.DisconnectReason.ROOM_CLOSED,
|
|
8
|
+
livekitClient.DisconnectReason.PARTICIPANT_REMOVED
|
|
9
|
+
]);
|
|
10
|
+
function isMedia(publication) {
|
|
11
|
+
return publication.kind === livekitClient.Track.Kind.Video || publication.kind === livekitClient.Track.Kind.Audio;
|
|
12
|
+
}
|
|
13
|
+
async function joinReceiveOnly(options) {
|
|
14
|
+
const { url, token, workerIdentity, video, audio, onEvent } = options;
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = new URL(url);
|
|
18
|
+
} catch {
|
|
19
|
+
throw new Error("live avatar: the room address is not a URL");
|
|
20
|
+
}
|
|
21
|
+
if (parsed.protocol !== "wss:") {
|
|
22
|
+
throw new Error("live avatar: the room address must be wss:");
|
|
23
|
+
}
|
|
24
|
+
if (!token || !workerIdentity) {
|
|
25
|
+
throw new Error("live avatar: the room token or speaker is missing");
|
|
26
|
+
}
|
|
27
|
+
const room = new livekitClient.Room({
|
|
28
|
+
// One small video, always shown while the session lasts: no quality switching, no layer pausing.
|
|
29
|
+
adaptiveStream: false,
|
|
30
|
+
dynacast: false,
|
|
31
|
+
// Closing the tab or leaving the page leaves the room at once. Belt and braces with the panel's own page-hide
|
|
32
|
+
// handler, which also asks the server to end the session: either may run first, and both are safe to repeat.
|
|
33
|
+
disconnectOnPageLeave: true
|
|
34
|
+
});
|
|
35
|
+
let leaving = false;
|
|
36
|
+
const isSpeaker = (participant) => participant.identity === workerIdentity;
|
|
37
|
+
const consider = (publication, participant) => {
|
|
38
|
+
if (leaving || !isSpeaker(participant) || !isMedia(publication)) return;
|
|
39
|
+
if (!publication.isSubscribed) publication.setSubscribed(true);
|
|
40
|
+
};
|
|
41
|
+
const reportPlayback = () => {
|
|
42
|
+
if (leaving) return;
|
|
43
|
+
onEvent({
|
|
44
|
+
type: "playback-blocked",
|
|
45
|
+
blocked: !room.canPlaybackAudio || !room.canPlaybackVideo
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
room.on(livekitClient.RoomEvent.TrackPublished, consider).on(
|
|
49
|
+
livekitClient.RoomEvent.TrackSubscribed,
|
|
50
|
+
(track, publication, participant) => {
|
|
51
|
+
if (leaving) return;
|
|
52
|
+
if (!isSpeaker(participant) || !isMedia(publication)) {
|
|
53
|
+
publication.setSubscribed(false);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (track.kind === livekitClient.Track.Kind.Video) {
|
|
57
|
+
track.attach(video);
|
|
58
|
+
onEvent({ type: "speaker-track", kind: "video" });
|
|
59
|
+
} else {
|
|
60
|
+
track.attach(audio);
|
|
61
|
+
onEvent({ type: "speaker-track", kind: "audio" });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
).on(livekitClient.RoomEvent.TrackUnsubscribed, (track) => {
|
|
65
|
+
track.detach();
|
|
66
|
+
}).on(livekitClient.RoomEvent.ParticipantDisconnected, (participant) => {
|
|
67
|
+
if (!leaving && isSpeaker(participant)) {
|
|
68
|
+
onEvent({ type: "speaker-left" });
|
|
69
|
+
}
|
|
70
|
+
}).on(livekitClient.RoomEvent.AudioPlaybackStatusChanged, reportPlayback).on(livekitClient.RoomEvent.VideoPlaybackStatusChanged, reportPlayback).on(livekitClient.RoomEvent.Disconnected, (reason) => {
|
|
71
|
+
if (leaving) return;
|
|
72
|
+
onEvent({
|
|
73
|
+
type: "disconnected",
|
|
74
|
+
byServer: reason !== void 0 && CLOSED_BY_SERVER.has(reason)
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
const detachAll = () => {
|
|
78
|
+
for (const participant of room.remoteParticipants.values()) {
|
|
79
|
+
for (const publication of participant.trackPublications.values()) {
|
|
80
|
+
publication.track?.detach();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
video.srcObject = null;
|
|
84
|
+
audio.srcObject = null;
|
|
85
|
+
};
|
|
86
|
+
try {
|
|
87
|
+
await room.connect(url, token, { autoSubscribe: false });
|
|
88
|
+
} catch (error) {
|
|
89
|
+
leaving = true;
|
|
90
|
+
room.removeAllListeners();
|
|
91
|
+
detachAll();
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
for (const participant of room.remoteParticipants.values()) {
|
|
95
|
+
for (const publication of participant.trackPublications.values()) {
|
|
96
|
+
consider(publication, participant);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
reportPlayback();
|
|
100
|
+
return {
|
|
101
|
+
enablePlayback: async () => {
|
|
102
|
+
await Promise.allSettled([room.startAudio(), room.startVideo()]);
|
|
103
|
+
reportPlayback();
|
|
104
|
+
},
|
|
105
|
+
leave: async () => {
|
|
106
|
+
if (leaving) return;
|
|
107
|
+
leaving = true;
|
|
108
|
+
detachAll();
|
|
109
|
+
try {
|
|
110
|
+
await room.disconnect();
|
|
111
|
+
} finally {
|
|
112
|
+
room.removeAllListeners();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
exports.joinReceiveOnly = joinReceiveOnly;
|
|
119
|
+
//# sourceMappingURL=index.js.map
|
|
120
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/avatar-live/receive-only.ts"],"names":["DisconnectReason","Track","Room","RoomEvent"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * BOFF-9797. The live avatar's room, joined to watch and listen only.\n *\n * A visitor's browser on a product website holds a subscribe-only room token: it can receive the avatar worker's\n * video and voice and do nothing else. This adapter keeps to that from its side too:\n *\n * - **Nothing is subscribed automatically** (`autoSubscribe: false`). Only the tracks of `workerIdentity`, the avatar\n * worker the create answer named, are subscribed, and only their video and audio. Any other participant's track\n * (the server's own speaker included) is never asked for, and is dropped again should the server send it anyway.\n * - **Nothing is published.** No microphone, no camera, no data, no RPC method: this file never calls a publishing\n * API, and the token would refuse one.\n * - **Safari and iOS** hold sound back until a gesture. The room reports it (`playback-blocked`) and the panel offers\n * \"Tap to turn on sound\", whose tap calls `enablePlayback`.\n *\n * Written for the website from the media library's public API: nothing here is copied from the product apps.\n */\nimport {\n DisconnectReason,\n Room,\n RoomEvent,\n Track,\n type RemoteParticipant,\n type RemoteTrack,\n type RemoteTrackPublication,\n} from \"livekit-client\";\n\nimport type { ReceiveOnlyJoinOptions, ReceiveOnlySession } from \"./types\";\n\n/** The reasons a room closes because the server closed it, rather than because the connection was lost. */\nconst CLOSED_BY_SERVER: ReadonlySet<DisconnectReason> = new Set([\n DisconnectReason.ROOM_DELETED,\n DisconnectReason.ROOM_CLOSED,\n DisconnectReason.PARTICIPANT_REMOVED,\n]);\n\nfunction isMedia(publication: RemoteTrackPublication): boolean {\n return (\n publication.kind === Track.Kind.Video ||\n publication.kind === Track.Kind.Audio\n );\n}\n\n/**\n * Join the room in `options.url` with the receive-only `options.token`, and play the avatar worker's video and voice\n * in `options.video` and `options.audio`. Resolves once connected; rejects when the room cannot be joined (the panel\n * then ends the session).\n */\nexport async function joinReceiveOnly(\n options: ReceiveOnlyJoinOptions,\n): Promise<ReceiveOnlySession> {\n const { url, token, workerIdentity, video, audio, onEvent } = options;\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new Error(\"live avatar: the room address is not a URL\");\n }\n if (parsed.protocol !== \"wss:\") {\n throw new Error(\"live avatar: the room address must be wss:\");\n }\n if (!token || !workerIdentity) {\n throw new Error(\"live avatar: the room token or speaker is missing\");\n }\n\n const room = new Room({\n // One small video, always shown while the session lasts: no quality switching, no layer pausing.\n adaptiveStream: false,\n dynacast: false,\n // Closing the tab or leaving the page leaves the room at once. Belt and braces with the panel's own page-hide\n // handler, which also asks the server to end the session: either may run first, and both are safe to repeat.\n disconnectOnPageLeave: true,\n });\n let leaving = false;\n\n const isSpeaker = (participant: RemoteParticipant) =>\n participant.identity === workerIdentity;\n\n /** Subscribe to `publication` when it is the speaker's video or audio; leave everything else alone. */\n const consider = (\n publication: RemoteTrackPublication,\n participant: RemoteParticipant,\n ) => {\n if (leaving || !isSpeaker(participant) || !isMedia(publication)) return;\n if (!publication.isSubscribed) publication.setSubscribed(true);\n };\n\n const reportPlayback = () => {\n if (leaving) return;\n onEvent({\n type: \"playback-blocked\",\n blocked: !room.canPlaybackAudio || !room.canPlaybackVideo,\n });\n };\n\n room\n .on(RoomEvent.TrackPublished, consider)\n .on(\n RoomEvent.TrackSubscribed,\n (\n track: RemoteTrack,\n publication: RemoteTrackPublication,\n participant: RemoteParticipant,\n ) => {\n if (leaving) return;\n if (!isSpeaker(participant) || !isMedia(publication)) {\n // Never asked for: drop it rather than play it.\n publication.setSubscribed(false);\n return;\n }\n if (track.kind === Track.Kind.Video) {\n track.attach(video);\n onEvent({ type: \"speaker-track\", kind: \"video\" });\n } else {\n track.attach(audio);\n onEvent({ type: \"speaker-track\", kind: \"audio\" });\n }\n },\n )\n .on(RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => {\n track.detach();\n })\n .on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => {\n if (!leaving && isSpeaker(participant)) {\n onEvent({ type: \"speaker-left\" });\n }\n })\n .on(RoomEvent.AudioPlaybackStatusChanged, reportPlayback)\n .on(RoomEvent.VideoPlaybackStatusChanged, reportPlayback)\n .on(RoomEvent.Disconnected, (reason?: DisconnectReason) => {\n if (leaving) return;\n onEvent({\n type: \"disconnected\",\n byServer: reason !== undefined && CLOSED_BY_SERVER.has(reason),\n });\n });\n\n const detachAll = () => {\n for (const participant of room.remoteParticipants.values()) {\n for (const publication of participant.trackPublications.values()) {\n publication.track?.detach();\n }\n }\n video.srcObject = null;\n audio.srcObject = null;\n };\n\n try {\n await room.connect(url, token, { autoSubscribe: false });\n } catch (error) {\n leaving = true;\n room.removeAllListeners();\n detachAll();\n throw error;\n }\n\n // Whoever was in the room before this viewer joined: their tracks were published before the listener above.\n for (const participant of room.remoteParticipants.values()) {\n for (const publication of participant.trackPublications.values()) {\n consider(publication, participant);\n }\n }\n reportPlayback();\n\n return {\n enablePlayback: async () => {\n // Both started inside the visitor's gesture, before anything is awaited: Safari allows playback only there.\n // A refusal leaves the room as it was, and the report below keeps the button offered.\n await Promise.allSettled([room.startAudio(), room.startVideo()]);\n reportPlayback();\n },\n leave: async () => {\n if (leaving) return;\n leaving = true;\n detachAll();\n try {\n await room.disconnect();\n } finally {\n room.removeAllListeners();\n }\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { DisconnectReason, Room, RoomEvent, Track } from 'livekit-client';
|
|
2
|
+
|
|
3
|
+
var CLOSED_BY_SERVER = /* @__PURE__ */ new Set([
|
|
4
|
+
DisconnectReason.ROOM_DELETED,
|
|
5
|
+
DisconnectReason.ROOM_CLOSED,
|
|
6
|
+
DisconnectReason.PARTICIPANT_REMOVED
|
|
7
|
+
]);
|
|
8
|
+
function isMedia(publication) {
|
|
9
|
+
return publication.kind === Track.Kind.Video || publication.kind === Track.Kind.Audio;
|
|
10
|
+
}
|
|
11
|
+
async function joinReceiveOnly(options) {
|
|
12
|
+
const { url, token, workerIdentity, video, audio, onEvent } = options;
|
|
13
|
+
let parsed;
|
|
14
|
+
try {
|
|
15
|
+
parsed = new URL(url);
|
|
16
|
+
} catch {
|
|
17
|
+
throw new Error("live avatar: the room address is not a URL");
|
|
18
|
+
}
|
|
19
|
+
if (parsed.protocol !== "wss:") {
|
|
20
|
+
throw new Error("live avatar: the room address must be wss:");
|
|
21
|
+
}
|
|
22
|
+
if (!token || !workerIdentity) {
|
|
23
|
+
throw new Error("live avatar: the room token or speaker is missing");
|
|
24
|
+
}
|
|
25
|
+
const room = new Room({
|
|
26
|
+
// One small video, always shown while the session lasts: no quality switching, no layer pausing.
|
|
27
|
+
adaptiveStream: false,
|
|
28
|
+
dynacast: false,
|
|
29
|
+
// Closing the tab or leaving the page leaves the room at once. Belt and braces with the panel's own page-hide
|
|
30
|
+
// handler, which also asks the server to end the session: either may run first, and both are safe to repeat.
|
|
31
|
+
disconnectOnPageLeave: true
|
|
32
|
+
});
|
|
33
|
+
let leaving = false;
|
|
34
|
+
const isSpeaker = (participant) => participant.identity === workerIdentity;
|
|
35
|
+
const consider = (publication, participant) => {
|
|
36
|
+
if (leaving || !isSpeaker(participant) || !isMedia(publication)) return;
|
|
37
|
+
if (!publication.isSubscribed) publication.setSubscribed(true);
|
|
38
|
+
};
|
|
39
|
+
const reportPlayback = () => {
|
|
40
|
+
if (leaving) return;
|
|
41
|
+
onEvent({
|
|
42
|
+
type: "playback-blocked",
|
|
43
|
+
blocked: !room.canPlaybackAudio || !room.canPlaybackVideo
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
room.on(RoomEvent.TrackPublished, consider).on(
|
|
47
|
+
RoomEvent.TrackSubscribed,
|
|
48
|
+
(track, publication, participant) => {
|
|
49
|
+
if (leaving) return;
|
|
50
|
+
if (!isSpeaker(participant) || !isMedia(publication)) {
|
|
51
|
+
publication.setSubscribed(false);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (track.kind === Track.Kind.Video) {
|
|
55
|
+
track.attach(video);
|
|
56
|
+
onEvent({ type: "speaker-track", kind: "video" });
|
|
57
|
+
} else {
|
|
58
|
+
track.attach(audio);
|
|
59
|
+
onEvent({ type: "speaker-track", kind: "audio" });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
).on(RoomEvent.TrackUnsubscribed, (track) => {
|
|
63
|
+
track.detach();
|
|
64
|
+
}).on(RoomEvent.ParticipantDisconnected, (participant) => {
|
|
65
|
+
if (!leaving && isSpeaker(participant)) {
|
|
66
|
+
onEvent({ type: "speaker-left" });
|
|
67
|
+
}
|
|
68
|
+
}).on(RoomEvent.AudioPlaybackStatusChanged, reportPlayback).on(RoomEvent.VideoPlaybackStatusChanged, reportPlayback).on(RoomEvent.Disconnected, (reason) => {
|
|
69
|
+
if (leaving) return;
|
|
70
|
+
onEvent({
|
|
71
|
+
type: "disconnected",
|
|
72
|
+
byServer: reason !== void 0 && CLOSED_BY_SERVER.has(reason)
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
const detachAll = () => {
|
|
76
|
+
for (const participant of room.remoteParticipants.values()) {
|
|
77
|
+
for (const publication of participant.trackPublications.values()) {
|
|
78
|
+
publication.track?.detach();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
video.srcObject = null;
|
|
82
|
+
audio.srcObject = null;
|
|
83
|
+
};
|
|
84
|
+
try {
|
|
85
|
+
await room.connect(url, token, { autoSubscribe: false });
|
|
86
|
+
} catch (error) {
|
|
87
|
+
leaving = true;
|
|
88
|
+
room.removeAllListeners();
|
|
89
|
+
detachAll();
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
for (const participant of room.remoteParticipants.values()) {
|
|
93
|
+
for (const publication of participant.trackPublications.values()) {
|
|
94
|
+
consider(publication, participant);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
reportPlayback();
|
|
98
|
+
return {
|
|
99
|
+
enablePlayback: async () => {
|
|
100
|
+
await Promise.allSettled([room.startAudio(), room.startVideo()]);
|
|
101
|
+
reportPlayback();
|
|
102
|
+
},
|
|
103
|
+
leave: async () => {
|
|
104
|
+
if (leaving) return;
|
|
105
|
+
leaving = true;
|
|
106
|
+
detachAll();
|
|
107
|
+
try {
|
|
108
|
+
await room.disconnect();
|
|
109
|
+
} finally {
|
|
110
|
+
room.removeAllListeners();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export { joinReceiveOnly };
|
|
117
|
+
//# sourceMappingURL=index.mjs.map
|
|
118
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/avatar-live/receive-only.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["/**\n * BOFF-9797. The live avatar's room, joined to watch and listen only.\n *\n * A visitor's browser on a product website holds a subscribe-only room token: it can receive the avatar worker's\n * video and voice and do nothing else. This adapter keeps to that from its side too:\n *\n * - **Nothing is subscribed automatically** (`autoSubscribe: false`). Only the tracks of `workerIdentity`, the avatar\n * worker the create answer named, are subscribed, and only their video and audio. Any other participant's track\n * (the server's own speaker included) is never asked for, and is dropped again should the server send it anyway.\n * - **Nothing is published.** No microphone, no camera, no data, no RPC method: this file never calls a publishing\n * API, and the token would refuse one.\n * - **Safari and iOS** hold sound back until a gesture. The room reports it (`playback-blocked`) and the panel offers\n * \"Tap to turn on sound\", whose tap calls `enablePlayback`.\n *\n * Written for the website from the media library's public API: nothing here is copied from the product apps.\n */\nimport {\n DisconnectReason,\n Room,\n RoomEvent,\n Track,\n type RemoteParticipant,\n type RemoteTrack,\n type RemoteTrackPublication,\n} from \"livekit-client\";\n\nimport type { ReceiveOnlyJoinOptions, ReceiveOnlySession } from \"./types\";\n\n/** The reasons a room closes because the server closed it, rather than because the connection was lost. */\nconst CLOSED_BY_SERVER: ReadonlySet<DisconnectReason> = new Set([\n DisconnectReason.ROOM_DELETED,\n DisconnectReason.ROOM_CLOSED,\n DisconnectReason.PARTICIPANT_REMOVED,\n]);\n\nfunction isMedia(publication: RemoteTrackPublication): boolean {\n return (\n publication.kind === Track.Kind.Video ||\n publication.kind === Track.Kind.Audio\n );\n}\n\n/**\n * Join the room in `options.url` with the receive-only `options.token`, and play the avatar worker's video and voice\n * in `options.video` and `options.audio`. Resolves once connected; rejects when the room cannot be joined (the panel\n * then ends the session).\n */\nexport async function joinReceiveOnly(\n options: ReceiveOnlyJoinOptions,\n): Promise<ReceiveOnlySession> {\n const { url, token, workerIdentity, video, audio, onEvent } = options;\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new Error(\"live avatar: the room address is not a URL\");\n }\n if (parsed.protocol !== \"wss:\") {\n throw new Error(\"live avatar: the room address must be wss:\");\n }\n if (!token || !workerIdentity) {\n throw new Error(\"live avatar: the room token or speaker is missing\");\n }\n\n const room = new Room({\n // One small video, always shown while the session lasts: no quality switching, no layer pausing.\n adaptiveStream: false,\n dynacast: false,\n // Closing the tab or leaving the page leaves the room at once. Belt and braces with the panel's own page-hide\n // handler, which also asks the server to end the session: either may run first, and both are safe to repeat.\n disconnectOnPageLeave: true,\n });\n let leaving = false;\n\n const isSpeaker = (participant: RemoteParticipant) =>\n participant.identity === workerIdentity;\n\n /** Subscribe to `publication` when it is the speaker's video or audio; leave everything else alone. */\n const consider = (\n publication: RemoteTrackPublication,\n participant: RemoteParticipant,\n ) => {\n if (leaving || !isSpeaker(participant) || !isMedia(publication)) return;\n if (!publication.isSubscribed) publication.setSubscribed(true);\n };\n\n const reportPlayback = () => {\n if (leaving) return;\n onEvent({\n type: \"playback-blocked\",\n blocked: !room.canPlaybackAudio || !room.canPlaybackVideo,\n });\n };\n\n room\n .on(RoomEvent.TrackPublished, consider)\n .on(\n RoomEvent.TrackSubscribed,\n (\n track: RemoteTrack,\n publication: RemoteTrackPublication,\n participant: RemoteParticipant,\n ) => {\n if (leaving) return;\n if (!isSpeaker(participant) || !isMedia(publication)) {\n // Never asked for: drop it rather than play it.\n publication.setSubscribed(false);\n return;\n }\n if (track.kind === Track.Kind.Video) {\n track.attach(video);\n onEvent({ type: \"speaker-track\", kind: \"video\" });\n } else {\n track.attach(audio);\n onEvent({ type: \"speaker-track\", kind: \"audio\" });\n }\n },\n )\n .on(RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => {\n track.detach();\n })\n .on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => {\n if (!leaving && isSpeaker(participant)) {\n onEvent({ type: \"speaker-left\" });\n }\n })\n .on(RoomEvent.AudioPlaybackStatusChanged, reportPlayback)\n .on(RoomEvent.VideoPlaybackStatusChanged, reportPlayback)\n .on(RoomEvent.Disconnected, (reason?: DisconnectReason) => {\n if (leaving) return;\n onEvent({\n type: \"disconnected\",\n byServer: reason !== undefined && CLOSED_BY_SERVER.has(reason),\n });\n });\n\n const detachAll = () => {\n for (const participant of room.remoteParticipants.values()) {\n for (const publication of participant.trackPublications.values()) {\n publication.track?.detach();\n }\n }\n video.srcObject = null;\n audio.srcObject = null;\n };\n\n try {\n await room.connect(url, token, { autoSubscribe: false });\n } catch (error) {\n leaving = true;\n room.removeAllListeners();\n detachAll();\n throw error;\n }\n\n // Whoever was in the room before this viewer joined: their tracks were published before the listener above.\n for (const participant of room.remoteParticipants.values()) {\n for (const publication of participant.trackPublications.values()) {\n consider(publication, participant);\n }\n }\n reportPlayback();\n\n return {\n enablePlayback: async () => {\n // Both started inside the visitor's gesture, before anything is awaited: Safari allows playback only there.\n // A refusal leaves the room as it was, and the report below keeps the button offered.\n await Promise.allSettled([room.startAudio(), room.startVideo()]);\n reportPlayback();\n },\n leave: async () => {\n if (leaving) return;\n leaving = true;\n detachAll();\n try {\n await room.disconnect();\n } finally {\n room.removeAllListeners();\n }\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
import { E as ExplorePanelAvatarLive, a as ExploreLocale, A as AvatarConversation } from '../explore-avatar-live-config-BGsGwbcq.mjs';
|
|
4
|
+
import '../types-CndbmBK-.mjs';
|
|
5
|
+
import '../use-explore-chat-zRuoAin6.mjs';
|
|
6
|
+
import '../explore-types-Bb2Ih3d8.mjs';
|
|
7
|
+
|
|
8
|
+
interface AvatarLiveViewProps {
|
|
9
|
+
live: ExplorePanelAvatarLive;
|
|
10
|
+
/** The Avatar view is showing: the panel is open and its Avatar tab is selected. */
|
|
11
|
+
active: boolean;
|
|
12
|
+
locale: ExploreLocale;
|
|
13
|
+
/** The product the panel speaks for, when it is one product's own; null on the portfolio's. */
|
|
14
|
+
productName: string | null;
|
|
15
|
+
/** The approved still picture. */
|
|
16
|
+
still: {
|
|
17
|
+
src: string;
|
|
18
|
+
alt: string;
|
|
19
|
+
};
|
|
20
|
+
/** The Avatar tab as it was before the live avatar: shown whenever the live avatar is not offered or not reachable. */
|
|
21
|
+
fallback: ReactNode;
|
|
22
|
+
/** Back to the Chat tab. Leaving the Avatar tab ends a session, like any other way out of it. */
|
|
23
|
+
onContinueInChat: () => void;
|
|
24
|
+
/** "Continue in Chat" in the panel's language: the panel's own word for it, so this chunk carries none of the panel's. */
|
|
25
|
+
continueInChat: string;
|
|
26
|
+
/**
|
|
27
|
+
* Nothing to ask the server about: an incomplete config, no gateway, no shared conversation to read (no provider, or a
|
|
28
|
+
* read-only shared one), or a locale whose strings have not been reviewed. The tab stays as it was. Decided by
|
|
29
|
+
* `ExploreAvatarLive`, which also decides whether this view is loaded at all; it can change while this view is
|
|
30
|
+
* mounted (the panel's locale switching), and what is under way then ends.
|
|
31
|
+
*/
|
|
32
|
+
off: boolean;
|
|
33
|
+
/** {@link avatarApiBase} of the SDK's gateway, null when there is none to use. */
|
|
34
|
+
base: string | null;
|
|
35
|
+
/** Headers every request carries: the alpha gateway's access headers, and nothing on production. */
|
|
36
|
+
headers: Readonly<Record<string, string>>;
|
|
37
|
+
/** The panel's conversation, null when there is no provider. */
|
|
38
|
+
conversation: AvatarConversation | null;
|
|
39
|
+
}
|
|
40
|
+
declare function AvatarLiveView({ live, active, locale, productName, still, fallback, onContinueInChat, continueInChat, off, base, headers, conversation, }: AvatarLiveViewProps): react.JSX.Element;
|
|
41
|
+
|
|
42
|
+
export { AvatarLiveView, type AvatarLiveViewProps };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
import { E as ExplorePanelAvatarLive, a as ExploreLocale, A as AvatarConversation } from '../explore-avatar-live-config-B171o1Yo.js';
|
|
4
|
+
import '../types-CndbmBK-.js';
|
|
5
|
+
import '../use-explore-chat-DunjG-3p.js';
|
|
6
|
+
import '../explore-types-Bb2Ih3d8.js';
|
|
7
|
+
|
|
8
|
+
interface AvatarLiveViewProps {
|
|
9
|
+
live: ExplorePanelAvatarLive;
|
|
10
|
+
/** The Avatar view is showing: the panel is open and its Avatar tab is selected. */
|
|
11
|
+
active: boolean;
|
|
12
|
+
locale: ExploreLocale;
|
|
13
|
+
/** The product the panel speaks for, when it is one product's own; null on the portfolio's. */
|
|
14
|
+
productName: string | null;
|
|
15
|
+
/** The approved still picture. */
|
|
16
|
+
still: {
|
|
17
|
+
src: string;
|
|
18
|
+
alt: string;
|
|
19
|
+
};
|
|
20
|
+
/** The Avatar tab as it was before the live avatar: shown whenever the live avatar is not offered or not reachable. */
|
|
21
|
+
fallback: ReactNode;
|
|
22
|
+
/** Back to the Chat tab. Leaving the Avatar tab ends a session, like any other way out of it. */
|
|
23
|
+
onContinueInChat: () => void;
|
|
24
|
+
/** "Continue in Chat" in the panel's language: the panel's own word for it, so this chunk carries none of the panel's. */
|
|
25
|
+
continueInChat: string;
|
|
26
|
+
/**
|
|
27
|
+
* Nothing to ask the server about: an incomplete config, no gateway, no shared conversation to read (no provider, or a
|
|
28
|
+
* read-only shared one), or a locale whose strings have not been reviewed. The tab stays as it was. Decided by
|
|
29
|
+
* `ExploreAvatarLive`, which also decides whether this view is loaded at all; it can change while this view is
|
|
30
|
+
* mounted (the panel's locale switching), and what is under way then ends.
|
|
31
|
+
*/
|
|
32
|
+
off: boolean;
|
|
33
|
+
/** {@link avatarApiBase} of the SDK's gateway, null when there is none to use. */
|
|
34
|
+
base: string | null;
|
|
35
|
+
/** Headers every request carries: the alpha gateway's access headers, and nothing on production. */
|
|
36
|
+
headers: Readonly<Record<string, string>>;
|
|
37
|
+
/** The panel's conversation, null when there is no provider. */
|
|
38
|
+
conversation: AvatarConversation | null;
|
|
39
|
+
}
|
|
40
|
+
declare function AvatarLiveView({ live, active, locale, productName, still, fallback, onContinueInChat, continueInChat, off, base, headers, conversation, }: AvatarLiveViewProps): react.JSX.Element;
|
|
41
|
+
|
|
42
|
+
export { AvatarLiveView, type AvatarLiveViewProps };
|