@nuancelabs/js-sdk 0.2.2 → 0.5.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.
Files changed (3) hide show
  1. package/index.d.ts +55 -7
  2. package/index.js +163 -18
  3. package/package.json +1 -1
package/index.d.ts CHANGED
@@ -216,8 +216,8 @@ export declare type ConversationEvent =
216
216
  /**
217
217
  * How long the live session has left, when it is capped.
218
218
  *
219
- * Emitted on each heartbeat, so it steps rather than ticks; a caller showing a
220
- * countdown should interpolate between them.
219
+ * Emitted when the time left is learned or corrected, so it steps rather
220
+ * than ticks; a caller showing a countdown should count down from the latest.
221
221
  */
222
222
  | {
223
223
  type: "time-remaining";
@@ -318,6 +318,26 @@ export declare interface CreateNuanceConversationOptions {
318
318
  * ```
319
319
  */
320
320
  token?: () => Promise<string>;
321
+ /**
322
+ * Who the avatar is in the conversation, such as your own
323
+ * {@link SessionPersonality.system_prompt}. Left out, it is the default
324
+ * personality.
325
+ *
326
+ * Only for a conversation the SDK creates with your API key (see
327
+ * {@link CreateNuanceConversationOptions.headers}). With a
328
+ * {@link CreateNuanceConversationOptions.token}, set it when your server
329
+ * mints the token instead: the token fixes it, and a conversation that sets
330
+ * its own is refused.
331
+ *
332
+ * @example
333
+ * ```ts
334
+ * createNuanceConversation({
335
+ * headers: () => ({ Authorization: `Bearer ${process.env.NUANCE_API_KEY}` }),
336
+ * personality: { system_prompt: "You are Sam, at Acme Bank..." },
337
+ * });
338
+ * ```
339
+ */
340
+ personality?: PersonalityChoice;
321
341
  /**
322
342
  * Capture constraints for the user's camera.
323
343
  *
@@ -344,9 +364,6 @@ export declare interface CreateNuanceConversationOptions {
344
364
  }
345
365
 
346
366
 
347
-
348
-
349
-
350
367
  /**
351
368
  * Raised by {@link Conversation.connect} when the server refuses a new session
352
369
  * because the account has not completed onboarding or accepted the current
@@ -405,7 +422,12 @@ export declare type MediaErrorReason = "permission-denied" | "device-not-found"
405
422
  */
406
423
  export declare type NetworkQuality = "good" | "poor" | "lost";
407
424
 
408
-
425
+ /**
426
+ * Who the avatar is in a conversation created with
427
+ * {@link CreateNuanceConversationOptions.personality}.
428
+ */
429
+ export declare interface PersonalityChoice extends SessionPersonality {
430
+ }
409
431
 
410
432
  /**
411
433
  * Raised by {@link Conversation.connect} when the server refuses a new session
@@ -428,11 +450,37 @@ export declare type SessionEndReason = "time-limit" | "ended" | "policy-violatio
428
450
 
429
451
 
430
452
 
453
+ /** Who the avatar is in a conversation. */
454
+ export declare interface SessionPersonality {
455
+ /**
456
+ * Your own prompt: who the avatar is, where it works, and what it knows
457
+ * about the person and the situation, written as you would brief a person
458
+ * before a call, in plain text.
459
+ */
460
+ system_prompt?: string;
461
+ }
462
+
463
+ /**
464
+ * What your server sends to `POST /session-tokens` with its API key. Every
465
+ * field is fixed for the conversation the token starts; send `{}` for the
466
+ * defaults.
467
+ */
468
+ export declare interface SessionTokenRequest {
469
+ /** Who the avatar is in the conversation. Leave it out for the default personality. */
470
+ personality?: SessionPersonality;
471
+ }
472
+
473
+ /** What `POST /session-tokens` returns. */
474
+ export declare interface SessionTokenResponse {
475
+ /** The session token, `st_…`: pass it to {@link CreateNuanceConversationOptions.token}. */
476
+ token: string;
477
+ /** When the token stops being able to start a conversation (UTC, ISO 8601). */
478
+ expires_at: string;
479
+ }
431
480
 
432
481
  /** When the avatar starts speaking: see {@link CreateNuanceConversationOptions.startOn}. */
433
482
  export declare type StartOn = "first-frame" | "full-quality";
434
483
 
435
-
436
484
  /**
437
485
  * The local user participant in a conversation.
438
486
  */
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.2.2"}`;
7
+ var SDK_HEADER = `js/${"0.5.0"}`;
8
8
 
9
9
  // src/utils.ts
10
10
  function normalizeHost(host) {
@@ -56,6 +56,9 @@ class NuanceApiClient {
56
56
  async heartbeatConversation(conversationId) {
57
57
  return await this.request(`/conversation/${conversationId}/heartbeat`, { method: "POST", token: this.tokens.get(conversationId) });
58
58
  }
59
+ async conversationEvents(conversationId, after, signal) {
60
+ return await this.request(`/conversation/${conversationId}/events?after=${after}`, { token: this.tokens.get(conversationId), signal });
61
+ }
59
62
  async abandonConversation(conversationId) {
60
63
  return await this.request(`/conversation/${conversationId}/abandon`, { method: "POST", token: this.tokens.get(conversationId) });
61
64
  }
@@ -71,7 +74,8 @@ class NuanceApiClient {
71
74
  const response = await fetch(`${this.host}${path}`, {
72
75
  method: options?.method ?? "GET",
73
76
  headers,
74
- body: options?.body !== undefined ? JSON.stringify(options.body) : undefined
77
+ body: options?.body !== undefined ? JSON.stringify(options.body) : undefined,
78
+ signal: options?.signal
75
79
  });
76
80
  if (!response.ok) {
77
81
  let detail = response.statusText;
@@ -2137,7 +2141,8 @@ class LiveKitConversation {
2137
2141
  var DEFAULT_HOST = "https://api.nuancelabs.ai";
2138
2142
  var QUEUE_HEARTBEAT_INTERVAL_MS = 5000;
2139
2143
  var HEARTBEAT_INTERVAL_MS = 5000;
2140
- var ADMISSION_FAILED_BEATS = 6;
2144
+ var EVENTS_RETRY_MS = 5000;
2145
+ var ADMISSION_FAILED_REQUESTS = 6;
2141
2146
  var BUSY_RETRY_LIMIT_MS = 60000;
2142
2147
  var BUSY_RETRY_DEFAULT_MS = 3000;
2143
2148
  var LOST_WINDOW_MS = 30000;
@@ -2204,6 +2209,7 @@ class NuanceConversation {
2204
2209
  delegate = null;
2205
2210
  delegateUnsubscribe = null;
2206
2211
  stopBeats = null;
2212
+ stopEvents = null;
2207
2213
  admitted = null;
2208
2214
  failedBeats = 0;
2209
2215
  resilient = false;
@@ -2292,10 +2298,8 @@ class NuanceConversation {
2292
2298
  this._id = conversation.id;
2293
2299
  this.held = conversation.id;
2294
2300
  this.emit({ type: "session-created", id: conversation.id });
2295
- this.admitted = Promise.withResolvers();
2296
- this.startHeartbeat(conversation.id, QUEUE_HEARTBEAT_INTERVAL_MS);
2297
- await this.admitted.promise;
2298
- this.delegate = await this.createDelegate(conversation, mediaDevices, attempt);
2301
+ const grant = await this.waitForSlot(conversation.id, attempt);
2302
+ this.delegate = await this.createDelegate(conversation, grant, mediaDevices, attempt);
2299
2303
  this.delegateUnsubscribe = this.delegate.onEvent((event) => {
2300
2304
  if (event.type === "state-changed") {
2301
2305
  this.setState(event.state);
@@ -2318,7 +2322,9 @@ class NuanceConversation {
2318
2322
  this.emit(event);
2319
2323
  });
2320
2324
  await this.delegate.connect();
2321
- this.startHeartbeat(conversation.id, HEARTBEAT_INTERVAL_MS);
2325
+ if (grant === null) {
2326
+ this.startHeartbeat(conversation.id, HEARTBEAT_INTERVAL_MS);
2327
+ }
2322
2328
  } catch (error) {
2323
2329
  if (attempt !== this.attempt) {
2324
2330
  throw error;
@@ -2332,6 +2338,8 @@ class NuanceConversation {
2332
2338
  this.attempt += 1;
2333
2339
  this.clearLimit();
2334
2340
  this.stopHeartbeat();
2341
+ this.stopEvents?.();
2342
+ this.stopEvents = null;
2335
2343
  this.admitted?.reject(new Error("Disconnected while waiting for a slot"));
2336
2344
  this.admitted = null;
2337
2345
  this.delegateUnsubscribe?.();
@@ -2390,19 +2398,146 @@ class NuanceConversation {
2390
2398
  }
2391
2399
  });
2392
2400
  }
2393
- async createDelegate(conversation, mediaDevices, attempt) {
2401
+ async waitForSlot(conversationId, attempt) {
2402
+ const admission = await this.followEvents(conversationId, attempt);
2403
+ if (admission !== null) {
2404
+ this.followLive(conversationId, admission.after, attempt);
2405
+ return admission.grant;
2406
+ }
2407
+ this.admitted = Promise.withResolvers();
2408
+ this.startHeartbeat(conversationId, QUEUE_HEARTBEAT_INTERVAL_MS);
2409
+ await this.admitted.promise;
2410
+ return null;
2411
+ }
2412
+ async followEvents(conversationId, attempt) {
2413
+ const abort = new AbortController;
2414
+ const stop = () => abort.abort();
2415
+ this.stopEvents = stop;
2416
+ let after = 0;
2417
+ let answered = false;
2418
+ let failures = 0;
2419
+ try {
2420
+ for (;; ) {
2421
+ let events;
2422
+ try {
2423
+ ({ events } = await this.api.conversationEvents(conversationId, after, abort.signal));
2424
+ } catch (error) {
2425
+ if (attempt !== this.attempt)
2426
+ throw new DisconnectedError;
2427
+ if (!answered && error instanceof ApiError && error.status === 404) {
2428
+ return null;
2429
+ }
2430
+ failures += 1;
2431
+ if (failures >= ADMISSION_FAILED_REQUESTS) {
2432
+ throw new Error("Lost contact with the server while waiting", {
2433
+ cause: error
2434
+ });
2435
+ }
2436
+ await this.pause(EVENTS_RETRY_MS, abort.signal);
2437
+ if (attempt !== this.attempt)
2438
+ throw new DisconnectedError;
2439
+ continue;
2440
+ }
2441
+ if (attempt !== this.attempt)
2442
+ throw new DisconnectedError;
2443
+ answered = true;
2444
+ failures = 0;
2445
+ for (const event of events) {
2446
+ if (event.type === "admitted") {
2447
+ const grant = {
2448
+ server_url: event.livekit_url,
2449
+ participant_token: event.token
2450
+ };
2451
+ return { grant, after: event.id };
2452
+ }
2453
+ after = Math.max(after, event.id);
2454
+ if (event.type === "ended") {
2455
+ throw new TerminalStatusError(event.reason);
2456
+ }
2457
+ if (event.type === "queued" && event.position != null) {
2458
+ this.reportPlace(event.position, event.estimated_wait_seconds ?? null, null);
2459
+ }
2460
+ }
2461
+ }
2462
+ } finally {
2463
+ if (this.stopEvents === stop)
2464
+ this.stopEvents = null;
2465
+ }
2466
+ }
2467
+ async followLive(conversationId, after, attempt) {
2468
+ const abort = new AbortController;
2469
+ const stop = () => abort.abort();
2470
+ this.stopEvents = stop;
2471
+ let failures = 0;
2472
+ try {
2473
+ while (attempt === this.attempt && !this.ended) {
2474
+ let events;
2475
+ try {
2476
+ ({ events } = await this.api.conversationEvents(conversationId, after, abort.signal));
2477
+ } catch {
2478
+ if (attempt !== this.attempt)
2479
+ return;
2480
+ failures += 1;
2481
+ await this.pause(EVENTS_RETRY_MS, abort.signal);
2482
+ continue;
2483
+ }
2484
+ if (attempt !== this.attempt)
2485
+ return;
2486
+ const receivedAt = Date.now();
2487
+ this.lostContact = failures > 0;
2488
+ failures = 0;
2489
+ for (const event of events) {
2490
+ after = Math.max(after, event.id);
2491
+ if (event.type === "started" && event.seconds_remaining != null) {
2492
+ this.capped = true;
2493
+ this.emit({
2494
+ type: "time-remaining",
2495
+ secondsRemaining: event.seconds_remaining
2496
+ });
2497
+ this.armLimit(event.seconds_remaining, receivedAt);
2498
+ }
2499
+ if (event.type === "ended") {
2500
+ this.endSession(this.endReason(event.reason));
2501
+ return;
2502
+ }
2503
+ }
2504
+ }
2505
+ } finally {
2506
+ if (this.stopEvents === stop)
2507
+ this.stopEvents = null;
2508
+ }
2509
+ }
2510
+ endReason(reason) {
2511
+ if (reason === "time_limit" || this.atLimit())
2512
+ return "time-limit";
2513
+ if (reason === "policy_violation")
2514
+ return "policy-violation";
2515
+ if (this.cutOff())
2516
+ return "connection-lost";
2517
+ return "ended";
2518
+ }
2519
+ pause(ms, signal) {
2520
+ return new Promise((resolve) => {
2521
+ let stopTicker = () => {};
2522
+ const done = () => {
2523
+ stopTicker();
2524
+ signal.removeEventListener("abort", done);
2525
+ resolve();
2526
+ };
2527
+ stopTicker = this.ticker(done, ms);
2528
+ signal.addEventListener("abort", done);
2529
+ });
2530
+ }
2531
+ async createDelegate(conversation, grant, mediaDevices, attempt) {
2394
2532
  if (conversation.provider !== "livekit") {
2395
2533
  throw new Error(`Unsupported provider ${conversation.provider}`);
2396
2534
  }
2397
- const response = await this.api.connectConversation(conversation.id);
2535
+ const connection = grant ?? await this.connectRoom(conversation.id);
2398
2536
  if (attempt !== this.attempt) {
2399
2537
  throw new DisconnectedError;
2400
2538
  }
2401
- if (response.connection.kind !== "livekit") {
2402
- throw new Error(`Expected LiveKit connection, received ${response.connection.kind}`);
2403
- }
2404
2539
  this.resilient = typeof this.resilientReconnect === "function" ? this.resilientReconnect() : this.resilientReconnect;
2405
- return this.provider(response.connection, {
2540
+ return this.provider(connection, {
2406
2541
  conversationId: conversation.id,
2407
2542
  mediaDevices,
2408
2543
  cameraEnabled: !this.sessionOverrides.disable_camera && mediaDevices.cameraEnabled !== false,
@@ -2411,6 +2546,13 @@ class NuanceConversation {
2411
2546
  resilientReconnect: this.resilient
2412
2547
  });
2413
2548
  }
2549
+ async connectRoom(conversationId) {
2550
+ const response = await this.api.connectConversation(conversationId);
2551
+ if (response.connection.kind !== "livekit") {
2552
+ throw new Error(`Expected LiveKit connection, received ${response.connection.kind}`);
2553
+ }
2554
+ return response.connection;
2555
+ }
2414
2556
  async createWhenNotBusy(request, attempt) {
2415
2557
  const giveUpAt = Date.now() + BUSY_RETRY_LIMIT_MS;
2416
2558
  for (;; ) {
@@ -2458,7 +2600,7 @@ class NuanceConversation {
2458
2600
  return;
2459
2601
  if (!(error instanceof TerminalStatusError)) {
2460
2602
  this.failedBeats += 1;
2461
- if (this.admitted && this.failedBeats >= ADMISSION_FAILED_BEATS) {
2603
+ if (this.admitted && this.failedBeats >= ADMISSION_FAILED_REQUESTS) {
2462
2604
  this.admitted.reject(new Error("Lost contact with the server while waiting", {
2463
2605
  cause: error
2464
2606
  }));
@@ -2497,14 +2639,17 @@ class NuanceConversation {
2497
2639
  if (state.status !== "queued" || state.position == null) {
2498
2640
  return;
2499
2641
  }
2642
+ this.reportPlace(state.position, state.estimated_wait_seconds ?? null, state.pool_slots ?? null);
2643
+ }
2644
+ reportPlace(position, estimate, poolSlots) {
2500
2645
  if (this._state !== "queued") {
2501
2646
  this.setState("queued");
2502
2647
  }
2503
2648
  this.emit({
2504
2649
  type: "queue-changed",
2505
- position: state.position,
2506
- estimatedWaitSeconds: this.smoothEstimate(state.position, state.estimated_wait_seconds ?? null),
2507
- poolSlots: state.pool_slots ?? null
2650
+ position,
2651
+ estimatedWaitSeconds: this.smoothEstimate(position, estimate),
2652
+ poolSlots
2508
2653
  });
2509
2654
  }
2510
2655
  smoothEstimate(position, estimate) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuancelabs/js-sdk",
3
- "version": "0.2.2",
3
+ "version": "0.5.0",
4
4
  "description": "JavaScript SDK for Nuance Labs conversation video AI avatar",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {