@nuancelabs/js-sdk 0.13.1 → 0.15.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 CHANGED
@@ -18,6 +18,7 @@ const conversation = createNuanceConversation({
18
18
  // Your endpoint, which mints a session token with your API key.
19
19
  token: async () => {
20
20
  const response = await fetch("/api/nuance-token", { method: "POST" });
21
+ if (!response.ok) throw new Error(await response.text());
21
22
  return (await response.json()).token;
22
23
  },
23
24
  });
package/index.d.ts CHANGED
@@ -86,6 +86,38 @@ export declare interface AgentTurnStartedEvent extends SessionEventBase {
86
86
  source: "reply" | "say" | (string & {});
87
87
  }
88
88
 
89
+ /**
90
+ * An error answer from the Nuance API that the SDK has no more specific error
91
+ * for, such as a `401` for an expired or used session token, a `429` when this
92
+ * month's conversation time is used, or a `503` once the SDK has stopped
93
+ * retrying a busy service. See the Errors guide for each status and `code`.
94
+ *
95
+ * Act on `status` and `code`, never on `message`: the message is the server's
96
+ * `detail`, written for you, and it can change.
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * try {
101
+ * await conversation.connect();
102
+ * } catch (error) {
103
+ * if (error instanceof ApiError && error.status === 401) {
104
+ * // The session token expired or was used: the next connect() fetches a new one.
105
+ * }
106
+ * }
107
+ * ```
108
+ */
109
+ export declare class ApiError extends Error {
110
+ /** The HTTP status, such as `401` or `503`. */
111
+ readonly status: number;
112
+ /** The error's `code` when the server sent one, such as `"busy"` or `"unavailable"`. */
113
+ readonly code: string | null;
114
+ /** How long to wait before trying again, when the server said. */
115
+ readonly retryAfterSeconds: number | null;
116
+ /** The parsed JSON body of the answer, or `null` when it had none. */
117
+ readonly body: unknown;
118
+ constructor(status: number, detail: string, body?: unknown);
119
+ }
120
+
89
121
  /**
90
122
  * An audio track that can be attached to or detached from an audio element.
91
123
  */
@@ -362,7 +394,8 @@ export declare type ConversationEvent =
362
394
  * `"time-limit"` when the session was capped and ran out its clock,
363
395
  * `"policy-violation"` when the runtime ended it for content, `"refused"`
364
396
  * when the runtime would not start it, with its reason in `message` when it
365
- * gave one.
397
+ * gave one. Call {@link Conversation.disconnect} then: after the runtime's
398
+ * own end the call stays connected, with the camera and microphone on.
366
399
  */
367
400
  | {
368
401
  type: "session-ended";
@@ -416,6 +449,7 @@ export declare type ConversationStateValue = `${ConversationState}`;
416
449
  * // Your server mints a session token with its API key.
417
450
  * token: async () => {
418
451
  * const response = await fetch("/api/nuance-token", { method: "POST" });
452
+ * if (!response.ok) throw new Error(await response.text());
419
453
  * return (await response.json()).token;
420
454
  * },
421
455
  * });
@@ -448,6 +482,7 @@ export declare interface CreateNuanceConversationOptions {
448
482
  * ```ts
449
483
  * token: async () => {
450
484
  * const response = await fetch("/api/nuance-token", { method: "POST" });
485
+ * if (!response.ok) throw new Error(await response.text());
451
486
  * return (await response.json()).token;
452
487
  * }
453
488
  * ```
@@ -704,8 +739,8 @@ export declare interface SessionPersonality {
704
739
 
705
740
  /**
706
741
  * What your server sends to `POST /session-tokens` with its API key. Every
707
- * field is fixed for the conversation the token starts; send `{}` for the
708
- * defaults.
742
+ * field is fixed for the conversation the token starts. Send `end_user_id`
743
+ * with every token; leave the other fields out for the defaults.
709
744
  */
710
745
  export declare interface SessionTokenRequest {
711
746
  /** Who the avatar is in the conversation. Leave it out for the default personality. */
package/index.js CHANGED
@@ -4,7 +4,7 @@ function isRecord(value) {
4
4
  }
5
5
 
6
6
  // src/core/sdk-version.ts
7
- var SDK_HEADER = `js/${"0.13.1"}`;
7
+ var SDK_HEADER = `js/${"0.15.0"}`;
8
8
 
9
9
  // src/utils.ts
10
10
  function normalizeHost(host) {
@@ -14,12 +14,16 @@ function normalizeHost(host) {
14
14
  // src/api/client.ts
15
15
  class ApiError extends Error {
16
16
  status;
17
+ code;
18
+ retryAfterSeconds;
17
19
  body;
18
20
  constructor(status, detail, body = null) {
19
21
  super(detail);
20
22
  this.name = "ApiError";
21
23
  this.status = status;
22
24
  this.body = body;
25
+ this.code = isRecord(body) && typeof body.code === "string" ? body.code : null;
26
+ this.retryAfterSeconds = isRecord(body) && typeof body.retry_after_seconds === "number" ? body.retry_after_seconds : null;
23
27
  }
24
28
  }
25
29
 
@@ -99,7 +103,6 @@ class NuanceApiClient {
99
103
  return this.headersProvider ? await this.headersProvider() : {};
100
104
  }
101
105
  }
102
-
103
106
  // src/core/active-session-error.ts
104
107
  class ActiveSessionError extends Error {
105
108
  constructor() {
@@ -1676,6 +1679,8 @@ var COMMAND_TIMEOUT_MS = 5000;
1676
1679
  var STREAM_TIMEOUT_MS = 30000;
1677
1680
  var BACKGROUND_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/;
1678
1681
  var AVATAR_RETURN_TIMEOUT_MS = 8000;
1682
+ var RUNTIME_RETURN_TIMEOUT_MS = 20000;
1683
+ var RUNTIME_RECHECK_MS = 1000;
1679
1684
  function contextItem(item) {
1680
1685
  const name = item.name === undefined ? {} : { name: item.name };
1681
1686
  return item.type === "text" ? { type: "text", text: item.text, ...name } : { type: "image", media_type: item.mediaType, data: item.data, ...name };
@@ -1738,6 +1743,7 @@ class LiveKitConversation {
1738
1743
  everConnected = false;
1739
1744
  away = false;
1740
1745
  awayTimer = null;
1746
+ runtimeTimer = null;
1741
1747
  presentationPaused = false;
1742
1748
  readySending = false;
1743
1749
  contextSent = null;
@@ -2049,10 +2055,14 @@ class LiveKitConversation {
2049
2055
  queueMicrotask(() => {
2050
2056
  if (this.room.state === ConnectionState2.Disconnected)
2051
2057
  return;
2052
- if (isReconnecting(this.room.state))
2058
+ if (isReconnecting(this.room.state)) {
2053
2059
  this.avatarAway();
2054
- else
2060
+ } else if (this.sessionEnded) {
2055
2061
  this.avatarLeft();
2062
+ } else if (this.avatarInRoom()) {} else {
2063
+ this.avatarAway();
2064
+ this.awaitRuntimeReturn();
2065
+ }
2056
2066
  });
2057
2067
  });
2058
2068
  this.room.on(RoomEvent3.Disconnected, () => {
@@ -2339,6 +2349,33 @@ class LiveKitConversation {
2339
2349
  this.avatarLeft();
2340
2350
  }, AVATAR_RETURN_TIMEOUT_MS);
2341
2351
  }
2352
+ awaitRuntimeReturn(delay = RUNTIME_RETURN_TIMEOUT_MS) {
2353
+ if (this.runtimeTimer !== null)
2354
+ return;
2355
+ this.runtimeTimer = setTimeout(() => {
2356
+ this.runtimeTimer = null;
2357
+ if (!this.away)
2358
+ return;
2359
+ if (isReconnecting(this.room.state)) {
2360
+ this.awaitRuntimeReturn(RUNTIME_RECHECK_MS);
2361
+ return;
2362
+ }
2363
+ if (this.room.state === ConnectionState2.Disconnected)
2364
+ return;
2365
+ if (this.avatarInRoom())
2366
+ return;
2367
+ this.clearAway();
2368
+ this.avatarLeft();
2369
+ this.reportEnd("connection-lost");
2370
+ }, delay);
2371
+ }
2372
+ avatarInRoom() {
2373
+ for (const [, participant] of this.room.remoteParticipants) {
2374
+ if (this.isAvatarParticipant(participant))
2375
+ return true;
2376
+ }
2377
+ return false;
2378
+ }
2342
2379
  avatarBack() {
2343
2380
  const resume = this.presentationPaused && this._avatar.isReady;
2344
2381
  this.clearAway();
@@ -2354,6 +2391,10 @@ class LiveKitConversation {
2354
2391
  clearTimeout(this.awayTimer);
2355
2392
  this.awayTimer = null;
2356
2393
  }
2394
+ if (this.runtimeTimer !== null) {
2395
+ clearTimeout(this.runtimeTimer);
2396
+ this.runtimeTimer = null;
2397
+ }
2357
2398
  }
2358
2399
  startLayerFallback() {
2359
2400
  if (this.layerFallback !== null)
@@ -2812,7 +2853,7 @@ class NuanceConversation {
2812
2853
  return "time-limit";
2813
2854
  if (reason === "policy_violation")
2814
2855
  return "policy-violation";
2815
- if (this.cutOff())
2856
+ if (reason === "runtime_silent" || this.cutOff())
2816
2857
  return "connection-lost";
2817
2858
  return "ended";
2818
2859
  }
@@ -3048,5 +3089,6 @@ export {
3048
3089
  LegalRequiredError,
3049
3090
  GuestLimitError,
3050
3091
  ConversationState,
3092
+ ApiError,
3051
3093
  ActiveSessionError
3052
3094
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuancelabs/js-sdk",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "description": "JavaScript SDK for Nuance Labs conversation video AI avatar",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -16,10 +16,22 @@
16
16
  ".": {
17
17
  "types": "./index.d.ts",
18
18
  "import": "./index.js"
19
+ },
20
+ "./react": {
21
+ "types": "./react/index.d.ts",
22
+ "import": "./react/index.js"
19
23
  }
20
24
  },
21
25
  "sideEffects": false,
22
26
  "dependencies": {
23
27
  "livekit-client": "^2.18.4"
28
+ },
29
+ "peerDependencies": {
30
+ "react": ">=18"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "react": {
34
+ "optional": true
35
+ }
24
36
  }
25
37
  }
@@ -0,0 +1,52 @@
1
+ import type { Conversation } from '@nuancelabs/js-sdk';
2
+ import type { ConversationStateValue } from '@nuancelabs/js-sdk';
3
+ import type { CreateNuanceConversationOptions } from '@nuancelabs/js-sdk';
4
+
5
+ /** What {@link useNuanceConversation} returns. */
6
+ export declare interface NuanceConversationHook {
7
+ /** The conversation's state; `"idle"` before the first start. */
8
+ readonly state: ConversationStateValue;
9
+ /**
10
+ * Why the last start or end failed, as thrown: test it with `instanceof`
11
+ * (`ApiError`, `MediaAccessError`, ...). Null after a start succeeds.
12
+ */
13
+ readonly error: Error | null;
14
+ /**
15
+ * The latest conversation, for anything the hook does not cover; kept after
16
+ * it ends, for its id. Null before the first start.
17
+ */
18
+ readonly conversation: Conversation | null;
19
+ /** Pass to a `<video>`'s `ref`: the avatar's video plays there. */
20
+ readonly videoRef: (element: HTMLVideoElement | null) => void;
21
+ /** Pass to an `<audio autoPlay>`'s `ref`: the avatar's voice plays there. */
22
+ readonly audioRef: (element: HTMLAudioElement | null) => void;
23
+ /** Starts a conversation, ending any current one. Call it from a click. */
24
+ readonly start: () => Promise<void>;
25
+ /** Ends the current conversation. */
26
+ readonly end: () => Promise<void>;
27
+ }
28
+
29
+ /**
30
+ * One conversation at a time in a React component: its state and error, refs
31
+ * for the elements the avatar plays in, and start and end. The hook leaves the
32
+ * conversation when the session ends (so the camera and microphone turn off)
33
+ * and when the component unmounts. Each start uses the options of the latest
34
+ * render.
35
+ *
36
+ * @example
37
+ * ```tsx
38
+ * const { state, error, videoRef, audioRef, start, end } =
39
+ * useNuanceConversation({ token: fetchToken });
40
+ *
41
+ * return (
42
+ * <>
43
+ * <video ref={videoRef} autoPlay playsInline />
44
+ * <audio ref={audioRef} autoPlay />
45
+ * <button onClick={start}>Start</button>
46
+ * </>
47
+ * );
48
+ * ```
49
+ */
50
+ export declare function useNuanceConversation(options: CreateNuanceConversationOptions): NuanceConversationHook;
51
+
52
+ export { }
package/react/index.js ADDED
@@ -0,0 +1,166 @@
1
+ // src/react/index.ts
2
+ import { useEffect, useState, useSyncExternalStore } from "react";
3
+
4
+ // src/react/store.ts
5
+ import {
6
+ createNuanceConversation
7
+ } from "@nuancelabs/js-sdk";
8
+ var IDLE = {
9
+ state: "idle",
10
+ error: null,
11
+ conversation: null
12
+ };
13
+ function asError(cause) {
14
+ return cause instanceof Error ? cause : new Error(String(cause));
15
+ }
16
+
17
+ class ConversationStore {
18
+ options;
19
+ create;
20
+ snapshot = IDLE;
21
+ listeners = new Set;
22
+ current = null;
23
+ unsubscribe = null;
24
+ video = null;
25
+ audio = null;
26
+ playingVideo = null;
27
+ playingAudio = null;
28
+ leaving = Promise.resolve();
29
+ constructor(options, create = createNuanceConversation) {
30
+ this.options = options;
31
+ this.create = create;
32
+ }
33
+ setOptions(options) {
34
+ this.options = options;
35
+ }
36
+ subscribe = (listener) => {
37
+ this.listeners.add(listener);
38
+ return () => this.listeners.delete(listener);
39
+ };
40
+ getSnapshot = () => this.snapshot;
41
+ setVideo = (element) => {
42
+ if (element === this.video)
43
+ return;
44
+ this.playingVideo?.detach();
45
+ this.playingVideo = null;
46
+ this.video = element;
47
+ this.attach();
48
+ };
49
+ setAudio = (element) => {
50
+ if (element === this.audio)
51
+ return;
52
+ this.playingAudio?.detach();
53
+ this.playingAudio = null;
54
+ this.audio = element;
55
+ this.attach();
56
+ };
57
+ start = async () => {
58
+ let conversation;
59
+ try {
60
+ conversation = this.create(this.options);
61
+ } catch (cause) {
62
+ this.update({ error: asError(cause) });
63
+ return;
64
+ }
65
+ const previous = this.release();
66
+ this.current = conversation;
67
+ this.unsubscribe = conversation.onChange((event) => {
68
+ if (this.current !== conversation)
69
+ return;
70
+ if (event.type === "state-changed")
71
+ this.update({ state: event.state });
72
+ if (event.type === "tracks-changed")
73
+ this.attach();
74
+ if (event.type === "session-ended")
75
+ this.end();
76
+ });
77
+ this.update({ state: conversation.state, error: null, conversation });
78
+ if (previous)
79
+ this.leave(previous).catch(() => {});
80
+ await this.leaving;
81
+ if (this.current !== conversation)
82
+ return;
83
+ try {
84
+ await conversation.connect();
85
+ } catch (cause) {
86
+ if (this.current === conversation)
87
+ this.update({ error: asError(cause) });
88
+ }
89
+ };
90
+ end = async () => {
91
+ const conversation = this.release();
92
+ if (!conversation)
93
+ return;
94
+ try {
95
+ await this.leave(conversation);
96
+ } catch (cause) {
97
+ if (this.current === null)
98
+ this.update({ error: asError(cause) });
99
+ }
100
+ if (this.current === null)
101
+ this.update({ state: conversation.state });
102
+ };
103
+ leave(conversation) {
104
+ const done = conversation.disconnect();
105
+ this.leaving = Promise.all([this.leaving, done.catch(() => {})]);
106
+ return done;
107
+ }
108
+ release() {
109
+ const conversation = this.current;
110
+ this.unsubscribe?.();
111
+ this.unsubscribe = null;
112
+ this.current = null;
113
+ this.playingVideo?.detach();
114
+ this.playingAudio?.detach();
115
+ this.playingVideo = null;
116
+ this.playingAudio = null;
117
+ return conversation;
118
+ }
119
+ attach() {
120
+ const conversation = this.current;
121
+ if (!conversation)
122
+ return;
123
+ let avatar;
124
+ try {
125
+ avatar = conversation.avatar;
126
+ } catch {
127
+ return;
128
+ }
129
+ const { videoTrack, audioTrack } = avatar;
130
+ if (this.video && videoTrack && videoTrack !== this.playingVideo) {
131
+ this.playingVideo?.detach();
132
+ videoTrack.attach(this.video);
133
+ this.playingVideo = videoTrack;
134
+ }
135
+ if (this.audio && audioTrack && audioTrack !== this.playingAudio) {
136
+ this.playingAudio?.detach();
137
+ audioTrack.attach(this.audio);
138
+ this.playingAudio = audioTrack;
139
+ }
140
+ }
141
+ update(change) {
142
+ this.snapshot = { ...this.snapshot, ...change };
143
+ for (const listener of this.listeners)
144
+ listener();
145
+ }
146
+ }
147
+
148
+ // src/react/index.ts
149
+ function useNuanceConversation(options) {
150
+ const [store] = useState(() => new ConversationStore(options));
151
+ useEffect(() => store.setOptions(options));
152
+ const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
153
+ useEffect(() => () => {
154
+ store.end();
155
+ }, [store]);
156
+ return {
157
+ ...snapshot,
158
+ videoRef: store.setVideo,
159
+ audioRef: store.setAudio,
160
+ start: store.start,
161
+ end: store.end
162
+ };
163
+ }
164
+ export {
165
+ useNuanceConversation
166
+ };