@convai/web-sdk 1.8.0-beta.5 → 1.8.0-beta.7
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 +333 -13
- package/dist/core/CharacterRoster.d.ts +84 -0
- package/dist/core/CharacterRoster.d.ts.map +1 -0
- package/dist/core/CharacterRoster.js +348 -0
- package/dist/core/CharacterRoster.js.map +1 -0
- package/dist/core/ConvaiClient.d.ts +152 -2
- package/dist/core/ConvaiClient.d.ts.map +1 -1
- package/dist/core/ConvaiClient.js +1036 -49
- package/dist/core/ConvaiClient.js.map +1 -1
- package/dist/core/ConvaiRoomError.d.ts +48 -0
- package/dist/core/ConvaiRoomError.d.ts.map +1 -0
- package/dist/core/ConvaiRoomError.js +111 -0
- package/dist/core/ConvaiRoomError.js.map +1 -0
- package/dist/core/MessageHandler.d.ts +25 -0
- package/dist/core/MessageHandler.d.ts.map +1 -1
- package/dist/core/MessageHandler.js +431 -30
- package/dist/core/MessageHandler.js.map +1 -1
- package/dist/core/SSESession.d.ts +15 -0
- package/dist/core/SSESession.d.ts.map +1 -1
- package/dist/core/SSESession.js +92 -3
- package/dist/core/SSESession.js.map +1 -1
- package/dist/core/connectRequest.d.ts +38 -2
- package/dist/core/connectRequest.d.ts.map +1 -1
- package/dist/core/connectRequest.js +57 -0
- package/dist/core/connectRequest.js.map +1 -1
- package/dist/core/index.d.ts +6 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +5 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/logicalTurn.d.ts +4 -1
- package/dist/core/logicalTurn.d.ts.map +1 -1
- package/dist/core/logicalTurn.js +35 -4
- package/dist/core/logicalTurn.js.map +1 -1
- package/dist/core/modelOutput.d.ts +40 -0
- package/dist/core/modelOutput.d.ts.map +1 -0
- package/dist/core/modelOutput.js +187 -0
- package/dist/core/modelOutput.js.map +1 -0
- package/dist/core/publishedChat.d.ts +9 -0
- package/dist/core/publishedChat.d.ts.map +1 -0
- package/dist/core/publishedChat.js +13 -0
- package/dist/core/publishedChat.js.map +1 -0
- package/dist/core/rosterRequest.d.ts +57 -0
- package/dist/core/rosterRequest.d.ts.map +1 -0
- package/dist/core/rosterRequest.js +210 -0
- package/dist/core/rosterRequest.js.map +1 -0
- package/dist/core/types.d.ts +667 -10
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/embed/ConvaiChatElement.d.ts +27 -0
- package/dist/embed/ConvaiChatElement.d.ts.map +1 -0
- package/dist/embed/ConvaiChatElement.js +300 -0
- package/dist/embed/ConvaiChatElement.js.map +1 -0
- package/dist/embed/browser.d.ts +3 -0
- package/dist/embed/browser.d.ts.map +1 -0
- package/dist/embed/browser.js +5 -0
- package/dist/embed/browser.js.map +1 -0
- package/dist/embed/chat-embed-v1.js +100 -0
- package/dist/embed/chat-embed-v1.js.map +7 -0
- package/dist/embed/index.d.ts +9 -0
- package/dist/embed/index.d.ts.map +1 -0
- package/dist/embed/index.js +9 -0
- package/dist/embed/index.js.map +1 -0
- package/dist/embed/publicationApi.d.ts +23 -0
- package/dist/embed/publicationApi.d.ts.map +1 -0
- package/dist/embed/publicationApi.js +141 -0
- package/dist/embed/publicationApi.js.map +1 -0
- package/dist/react/components/ConvaiWidget.d.ts +11 -1
- package/dist/react/components/ConvaiWidget.d.ts.map +1 -1
- package/dist/react/components/ConvaiWidget.js +67 -11
- package/dist/react/components/ConvaiWidget.js.map +1 -1
- package/dist/react/components/rtc-widget/components/MarkdownRenderer.d.ts.map +1 -1
- package/dist/react/components/rtc-widget/components/MarkdownRenderer.js +39 -44
- package/dist/react/components/rtc-widget/components/MarkdownRenderer.js.map +1 -1
- package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.d.ts +1 -1
- package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.d.ts.map +1 -1
- package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.js +2 -2
- package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.js.map +1 -1
- package/dist/react/hooks/useConvaiClient.d.ts +2 -0
- package/dist/react/hooks/useConvaiClient.d.ts.map +1 -1
- package/dist/react/hooks/useConvaiClient.js +16 -0
- package/dist/react/hooks/useConvaiClient.js.map +1 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -1
- package/dist/react/index.js.map +1 -1
- package/dist/utils/inlineMarkdown.d.ts +38 -0
- package/dist/utils/inlineMarkdown.d.ts.map +1 -0
- package/dist/utils/inlineMarkdown.js +106 -0
- package/dist/utils/inlineMarkdown.js.map +1 -0
- package/dist/vanilla/AudioRenderer.d.ts.map +1 -1
- package/dist/vanilla/AudioRenderer.js +6 -0
- package/dist/vanilla/AudioRenderer.js.map +1 -1
- package/dist/vanilla/ConvaiWidget.d.ts.map +1 -1
- package/dist/vanilla/ConvaiWidget.js +224 -89
- package/dist/vanilla/ConvaiWidget.js.map +1 -1
- package/dist/vanilla/WidgetEventSubscriptions.d.ts +15 -0
- package/dist/vanilla/WidgetEventSubscriptions.d.ts.map +1 -0
- package/dist/vanilla/WidgetEventSubscriptions.js +37 -0
- package/dist/vanilla/WidgetEventSubscriptions.js.map +1 -0
- package/dist/vanilla/index.d.ts +2 -1
- package/dist/vanilla/index.d.ts.map +1 -1
- package/dist/vanilla/index.js +2 -1
- package/dist/vanilla/index.js.map +1 -1
- package/dist/vanilla/types.d.ts +8 -1
- package/dist/vanilla/types.d.ts.map +1 -1
- package/dist/vanilla/types.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +19 -6
|
@@ -5,16 +5,20 @@ import { AudioManager } from "./AudioManager.js";
|
|
|
5
5
|
import { VideoManager } from "./VideoManager.js";
|
|
6
6
|
import { ScreenShareManager } from "./ScreenShareManager.js";
|
|
7
7
|
import { MessageHandler } from "./MessageHandler.js";
|
|
8
|
+
import { CharacterRoster } from "./CharacterRoster.js";
|
|
9
|
+
import { buildJoinRequestBody, buildRosterConnectFields, isRosterConfig, validateJoinOptions, validateProtocolTopology, validateRosterConfig, } from "./rosterRequest.js";
|
|
10
|
+
import { ConvaiRoomError, roomErrorFromResponse } from "./ConvaiRoomError.js";
|
|
8
11
|
import { SDK_VERSION } from "../version.js";
|
|
9
12
|
import { EventEmitter } from "./EventEmitter.js";
|
|
10
13
|
import { MemoryManager } from "./MemoryManager.js";
|
|
11
14
|
import { CharacterVersionManager } from "./CharacterVersionManager.js";
|
|
12
15
|
import { resolveCharacterReference, } from "./characterReference.js";
|
|
13
16
|
import { ConnectionStateHandler } from "./ConnectionStateHandler.js";
|
|
14
|
-
import { buildActionConnectConfig, buildBlendshapeConnectConfig, buildEmotionConnectConfig, } from "./connectRequest.js";
|
|
17
|
+
import { buildActionConnectConfig, buildBlendshapeConnectConfig, buildEmotionConnectConfig, serializeProtocolCapabilities, validateActionProtocolSelection, validateModelOutputVersionSelection, } from "./connectRequest.js";
|
|
15
18
|
import { shouldPreemptForContextUpdateResponse, shouldPreemptForExplicitRespondMode, } from "./contextUpdateRequest.js";
|
|
16
19
|
import { resolveVisionInputConfig, serializeVisionInputConfig, } from "./visionRequest.js";
|
|
17
20
|
import { SSESession } from "./SSESession.js";
|
|
21
|
+
import { isJsonValue } from "./modelOutput.js";
|
|
18
22
|
import { isValidLogicalTurnId } from "./logicalTurn.js";
|
|
19
23
|
// Module-level registry — populated by importing @convai/web-sdk/vanilla/websocket
|
|
20
24
|
let _wsFactory = null;
|
|
@@ -24,6 +28,33 @@ function normalizeStateOfMind(value) {
|
|
|
24
28
|
const normalized = value.trim().toLowerCase();
|
|
25
29
|
return normalized === "neutral" || normalized === "" ? null : normalized;
|
|
26
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Rejection from {@link ConvaiClient.sendActionResultAndWait} after local
|
|
33
|
+
* validation succeeds. Inspect `reason` rather than parsing the message.
|
|
34
|
+
*/
|
|
35
|
+
export class ActionResultAckError extends Error {
|
|
36
|
+
constructor(message, options) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name =
|
|
39
|
+
options.reason === "aborted"
|
|
40
|
+
? "AbortError"
|
|
41
|
+
: options.reason === "timeout"
|
|
42
|
+
? "TimeoutError"
|
|
43
|
+
: "ActionResultAckError";
|
|
44
|
+
this.reason = options.reason;
|
|
45
|
+
this.toolCallId = options.toolCallId;
|
|
46
|
+
this.errorCode = options.errorCode;
|
|
47
|
+
this.response = options.response;
|
|
48
|
+
this.cause = options.cause;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Marks a publication exchange failure that is safe to retry with the same
|
|
53
|
+
* logical connect attempt. The launch grant itself is single-use, so callers
|
|
54
|
+
* must never mint or submit a second grant for this recovery path.
|
|
55
|
+
*/
|
|
56
|
+
class RetryablePublishedChatExchangeError extends Error {
|
|
57
|
+
}
|
|
27
58
|
/**
|
|
28
59
|
* Custom reconnect policy that disables automatic reconnection
|
|
29
60
|
*/
|
|
@@ -88,6 +119,12 @@ export class ConvaiClient extends EventEmitter {
|
|
|
88
119
|
this._characterVersionManager = null;
|
|
89
120
|
this._characterVersionManagerKey = null;
|
|
90
121
|
this._characterSessionId = "-1";
|
|
122
|
+
/** Roster state for multi-character rooms. Empty for single-character sessions. */
|
|
123
|
+
this._roster = new CharacterRoster();
|
|
124
|
+
/** Roster commands awaiting their ack, keyed by command id. */
|
|
125
|
+
this._pendingRosterCommands = new Map();
|
|
126
|
+
this._rosterCommandSeq = 0;
|
|
127
|
+
this._requestTraceId = null;
|
|
91
128
|
this._isBotReady = false;
|
|
92
129
|
this._participantSid = "";
|
|
93
130
|
this._storedConfig = null;
|
|
@@ -103,10 +140,12 @@ export class ConvaiClient extends EventEmitter {
|
|
|
103
140
|
this._sseSession = null;
|
|
104
141
|
this._logRtviMessages = true;
|
|
105
142
|
this._isDisconnecting = false;
|
|
143
|
+
this._isPublishedChatSession = false;
|
|
106
144
|
// RTVI client-ready handshake (race-proof bot-ready delivery)
|
|
107
145
|
this._clientReadyRetryTimer = null;
|
|
108
146
|
this._clientReadyTimeoutTimer = null;
|
|
109
147
|
this._clientReadyMsgId = null;
|
|
148
|
+
this._pendingActionResultAcks = new Map();
|
|
110
149
|
// Conversation session tracking
|
|
111
150
|
this._conversationSessionId = 0;
|
|
112
151
|
this._conversationStartTime = 0;
|
|
@@ -116,6 +155,23 @@ export class ConvaiClient extends EventEmitter {
|
|
|
116
155
|
if (config) {
|
|
117
156
|
this._storedConfig = config;
|
|
118
157
|
this._logRtviMessages = config.logRtviMessages !== false;
|
|
158
|
+
// Derive the version readouts from the config eagerly, as
|
|
159
|
+
// characterVersions already is, so a React consumer destructuring
|
|
160
|
+
// characterReference off the hook sees the selector on first render
|
|
161
|
+
// rather than null until connect(). A malformed selector is left for
|
|
162
|
+
// connect() to reject -- the constructor has never thrown on config and
|
|
163
|
+
// starting to would be a behaviour change.
|
|
164
|
+
if (config.characterId && !isRosterConfig(config)) {
|
|
165
|
+
try {
|
|
166
|
+
const ref = resolveCharacterReference(config.characterId, config.characterVersion);
|
|
167
|
+
this._characterId = ref.characterId;
|
|
168
|
+
this._characterVersion = ref.version;
|
|
169
|
+
this._characterReference = ref.reference;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
/* reported by connect() */
|
|
173
|
+
}
|
|
174
|
+
}
|
|
119
175
|
}
|
|
120
176
|
// Initialize room with no reconnect policy
|
|
121
177
|
this._room = new Room({
|
|
@@ -133,6 +189,8 @@ export class ConvaiClient extends EventEmitter {
|
|
|
133
189
|
endUserId: null,
|
|
134
190
|
endUserMetadata: null,
|
|
135
191
|
metrics: [],
|
|
192
|
+
characters: [],
|
|
193
|
+
activeMembershipId: null,
|
|
136
194
|
disconnectReason: null,
|
|
137
195
|
};
|
|
138
196
|
// Default audio settings for optimal interruption handling
|
|
@@ -166,6 +224,9 @@ export class ConvaiClient extends EventEmitter {
|
|
|
166
224
|
get connectionType() {
|
|
167
225
|
return this._connectionType;
|
|
168
226
|
}
|
|
227
|
+
get isPublishedChatSession() {
|
|
228
|
+
return this._isPublishedChatSession;
|
|
229
|
+
}
|
|
169
230
|
get apiKey() {
|
|
170
231
|
return this._apiKey;
|
|
171
232
|
}
|
|
@@ -223,6 +284,48 @@ export class ConvaiClient extends EventEmitter {
|
|
|
223
284
|
get characterSessionId() {
|
|
224
285
|
return this._characterSessionId;
|
|
225
286
|
}
|
|
287
|
+
on(event, callback) {
|
|
288
|
+
return super.on(event, callback);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Server-owned snapshot of a multi-character room, or null for a
|
|
292
|
+
* single-character session. Carries the room id, epochs and roster.
|
|
293
|
+
*/
|
|
294
|
+
get roomSession() {
|
|
295
|
+
return this._roster.roomSession;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Character instances in a multi-character room, in roster order. Empty for a
|
|
299
|
+
* single-character session, so `length` distinguishes the two.
|
|
300
|
+
*/
|
|
301
|
+
get characters() {
|
|
302
|
+
return this._roster.characters;
|
|
303
|
+
}
|
|
304
|
+
/** Membership currently receiving user turns, or null. */
|
|
305
|
+
get activeMembershipId() {
|
|
306
|
+
return this._roster.activeMembershipId;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Start or stop the server streaming per-turn usage/cost breakdowns as
|
|
310
|
+
* `usageUpdate` events. Informational only; server-side usage tracking and
|
|
311
|
+
* billing are unaffected either way.
|
|
312
|
+
*/
|
|
313
|
+
toggleUsageUpdates(enabled) {
|
|
314
|
+
if (!this.isTransportReady())
|
|
315
|
+
return;
|
|
316
|
+
this.publishMessage("usage-toggle", { enabled });
|
|
317
|
+
}
|
|
318
|
+
/** Insert a transcript line locally. Nothing is sent; nothing is billed. */
|
|
319
|
+
appendMessage(message) {
|
|
320
|
+
if (!message?.content?.trim()) {
|
|
321
|
+
throw new Error("appendMessage requires non-empty content");
|
|
322
|
+
}
|
|
323
|
+
return this._messageHandler.appendLocalMessage(message);
|
|
324
|
+
}
|
|
325
|
+
/** Trace id of the /connect that produced this session; quote it to backend support. */
|
|
326
|
+
get requestTraceId() {
|
|
327
|
+
return this._requestTraceId;
|
|
328
|
+
}
|
|
226
329
|
get isBotReady() {
|
|
227
330
|
return this._isBotReady;
|
|
228
331
|
}
|
|
@@ -249,12 +352,60 @@ export class ConvaiClient extends EventEmitter {
|
|
|
249
352
|
*/
|
|
250
353
|
setupEventListeners() {
|
|
251
354
|
// Room event listeners
|
|
355
|
+
// Per-membership audio binding. In a roster room every character is its
|
|
356
|
+
// own participant with identity `character:{membership_id}`; the guide's
|
|
357
|
+
// rule is to bind a track to the exact member or drop it, never to fall
|
|
358
|
+
// back to the active character. Single-character rooms keep the legacy
|
|
359
|
+
// behaviour: the track is surfaced with no attribution.
|
|
360
|
+
this._room.on(RoomEvent.TrackSubscribed, (track, _publication, participant) => {
|
|
361
|
+
if (track.kind !== "audio")
|
|
362
|
+
return;
|
|
363
|
+
const identity = participant.identity;
|
|
364
|
+
if (!this._roster.isMultiCharacter) {
|
|
365
|
+
this.emit("characterAudioTrack", {
|
|
366
|
+
membershipId: null,
|
|
367
|
+
characterId: this._characterId,
|
|
368
|
+
participantIdentity: identity,
|
|
369
|
+
track: track.mediaStreamTrack,
|
|
370
|
+
});
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const membershipId = identity.startsWith("character:")
|
|
374
|
+
? identity.slice("character:".length)
|
|
375
|
+
: null;
|
|
376
|
+
const member = membershipId ? this._roster.find(membershipId) : undefined;
|
|
377
|
+
if (!member) {
|
|
378
|
+
// Unattributable in a roster room: quarantine rather than guess.
|
|
379
|
+
if (this._logRtviMessages) {
|
|
380
|
+
console.warn(`[ConvaiClient] dropped audio track from unattributed participant "${identity}"`);
|
|
381
|
+
}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
this.emit("characterAudioTrack", {
|
|
385
|
+
membershipId: member.membershipId,
|
|
386
|
+
characterId: member.characterId,
|
|
387
|
+
participantIdentity: identity,
|
|
388
|
+
track: track.mediaStreamTrack,
|
|
389
|
+
});
|
|
390
|
+
});
|
|
252
391
|
this._room.on(RoomEvent.Disconnected, (reason) => {
|
|
253
392
|
this._connectionStateHandler.handleDisconnected(reason);
|
|
254
393
|
});
|
|
255
394
|
this._room.on(RoomEvent.ConnectionStateChanged, this._connectionStateHandler.handleConnectionStateChanged.bind(this._connectionStateHandler));
|
|
256
395
|
// Message handler events
|
|
257
|
-
this._messageHandler.on("botReady", () => {
|
|
396
|
+
this._messageHandler.on("botReady", (about) => {
|
|
397
|
+
// Each roster member sends its own bot-ready. Feed the roster before the
|
|
398
|
+
// de-dupe below, which only guards the single consumer-facing event --
|
|
399
|
+
// otherwise every member after the first would be dropped.
|
|
400
|
+
if (this._roster.applyBotReady(about)) {
|
|
401
|
+
this.updateState({
|
|
402
|
+
characters: this._roster.characters,
|
|
403
|
+
activeMembershipId: this._roster.activeMembershipId,
|
|
404
|
+
});
|
|
405
|
+
const membershipId = about?.membership_id;
|
|
406
|
+
if (membershipId)
|
|
407
|
+
this.emit("characterReady", this._roster.find(membershipId));
|
|
408
|
+
}
|
|
258
409
|
// Server may send multiple bot-ready frames (one per client-ready
|
|
259
410
|
// we retried). Only surface the first to consumers per connect.
|
|
260
411
|
if (this._isBotReady) {
|
|
@@ -265,8 +416,17 @@ export class ConvaiClient extends EventEmitter {
|
|
|
265
416
|
this.updateState({ isConnected: true });
|
|
266
417
|
this.emit("botReady");
|
|
267
418
|
});
|
|
268
|
-
this._messageHandler.on("messagesChange", (messages) => {
|
|
269
|
-
|
|
419
|
+
this._messageHandler.on("messagesChange", (messages, updatedMessage, updatedMessages) => {
|
|
420
|
+
// Most updates target the tail. Correlated v2 chunks can update an
|
|
421
|
+
// earlier streaming row. A v2 stop can finalize several rows at once.
|
|
422
|
+
if (updatedMessages?.length) {
|
|
423
|
+
for (const message of updatedMessages) {
|
|
424
|
+
this.emit("message", message);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
this.emit("message", updatedMessage ?? messages[messages.length - 1]);
|
|
429
|
+
}
|
|
270
430
|
this.emit("messagesChange", messages);
|
|
271
431
|
});
|
|
272
432
|
this._messageHandler.on("userTranscriptionChange", (transcription) => {
|
|
@@ -348,6 +508,9 @@ export class ConvaiClient extends EventEmitter {
|
|
|
348
508
|
this._messageHandler.on("llmNoResponse", () => {
|
|
349
509
|
this.emit("llmNoResponse");
|
|
350
510
|
});
|
|
511
|
+
this._messageHandler.on("botTurnCompleted", (data) => {
|
|
512
|
+
this.emit("botTurnCompleted", data);
|
|
513
|
+
});
|
|
351
514
|
// Forward bot-output event (aggregated output with spoken status)
|
|
352
515
|
this._messageHandler.on("botOutput", (data) => {
|
|
353
516
|
this.emit("botOutput", data);
|
|
@@ -373,7 +536,29 @@ export class ConvaiClient extends EventEmitter {
|
|
|
373
536
|
this._messageHandler.on("actionResponse", (data) => {
|
|
374
537
|
this.emit("actionResponse", data);
|
|
375
538
|
});
|
|
539
|
+
this._messageHandler.on("usageUpdate", (data) => {
|
|
540
|
+
this.emit("usageUpdate", data);
|
|
541
|
+
});
|
|
542
|
+
this._messageHandler.on("characterStatus", (status) => {
|
|
543
|
+
if (this._roster.applyCharacterStatus(status)) {
|
|
544
|
+
this.updateState({
|
|
545
|
+
characters: this._roster.characters,
|
|
546
|
+
activeMembershipId: this._roster.activeMembershipId,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
this.emit("characterStatus", status);
|
|
550
|
+
});
|
|
551
|
+
this._messageHandler.on("modelOutput", (data) => {
|
|
552
|
+
this.emit("modelOutput", data);
|
|
553
|
+
});
|
|
554
|
+
this._messageHandler.on("botLlmTextRaw", (data) => {
|
|
555
|
+
this.emit("botLlmTextRaw", data);
|
|
556
|
+
});
|
|
557
|
+
this._messageHandler.on("modelOutputProtocolError", (data) => {
|
|
558
|
+
this.emit("modelOutputProtocolError", data);
|
|
559
|
+
});
|
|
376
560
|
this._messageHandler.on("serverResponse", (response) => {
|
|
561
|
+
this._handleRosterServerResponse(response);
|
|
377
562
|
this.emit("serverResponse", response);
|
|
378
563
|
});
|
|
379
564
|
this._messageHandler.on("interactionCreated", (data) => {
|
|
@@ -437,6 +622,30 @@ export class ConvaiClient extends EventEmitter {
|
|
|
437
622
|
this._room.localParticipant.publishData(encodedData, { reliable: true });
|
|
438
623
|
}
|
|
439
624
|
}
|
|
625
|
+
/**
|
|
626
|
+
* Publish exactly one message while retaining the transport's local completion
|
|
627
|
+
* signal. Server acceptance is still proved separately by `serverResponse`.
|
|
628
|
+
*/
|
|
629
|
+
async publishMessageAndWaitForLocalPublication(type, data, id) {
|
|
630
|
+
const message = id === undefined ? { type, data } : { type, id, data };
|
|
631
|
+
if (this._activeTransport === "websocket") {
|
|
632
|
+
if (!this._wsSession?.isConnected) {
|
|
633
|
+
throw new Error("The action-result WebSocket transport is no longer available");
|
|
634
|
+
}
|
|
635
|
+
this._wsSession.sendMessage(type, data);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (this._activeTransport === "livekit" &&
|
|
639
|
+
this._room?.localParticipant &&
|
|
640
|
+
this._room.state !== "disconnected") {
|
|
641
|
+
const encodedData = new TextEncoder().encode(JSON.stringify(message));
|
|
642
|
+
await this._room.localParticipant.publishData(encodedData, {
|
|
643
|
+
reliable: true,
|
|
644
|
+
});
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
throw new Error("The action-result transport is no longer available");
|
|
648
|
+
}
|
|
440
649
|
/**
|
|
441
650
|
* Returns true when a message can be sent on the active transport.
|
|
442
651
|
*/
|
|
@@ -460,13 +669,18 @@ export class ConvaiClient extends EventEmitter {
|
|
|
460
669
|
}
|
|
461
670
|
// Store config for reconnection
|
|
462
671
|
this._storedConfig = { ...finalConfig };
|
|
672
|
+
this._isPublishedChatSession = false;
|
|
463
673
|
// Use characterSessionId from config when reconnecting (so we resume the same session)
|
|
464
674
|
if (finalConfig.characterSessionId &&
|
|
465
675
|
finalConfig.characterSessionId !== "-1") {
|
|
466
676
|
this._characterSessionId = finalConfig.characterSessionId;
|
|
467
677
|
}
|
|
468
678
|
const configWithDefaults = this.applyConfigDefaults(finalConfig);
|
|
679
|
+
// A fresh connect starts on the v1 model-output contract; the /connect
|
|
680
|
+
// response upgrades it once the server confirms the negotiated version.
|
|
681
|
+
this._messageHandler.setModelOutputVersion(1);
|
|
469
682
|
this.updateState({ isConnecting: true });
|
|
683
|
+
this._requestTraceId = null;
|
|
470
684
|
try {
|
|
471
685
|
// Store connection config
|
|
472
686
|
this._apiKey = configWithDefaults.apiKey ?? null;
|
|
@@ -520,6 +734,14 @@ export class ConvaiClient extends EventEmitter {
|
|
|
520
734
|
enableEmotion: configWithDefaults.enableEmotion,
|
|
521
735
|
emotionConfig: configWithDefaults.emotionConfig,
|
|
522
736
|
actionConfig: configWithDefaults.actionConfig,
|
|
737
|
+
capabilities: configWithDefaults.capabilities,
|
|
738
|
+
dynamicInfo: configWithDefaults.dynamicInfo,
|
|
739
|
+
keepInContext: configWithDefaults.keepInContext,
|
|
740
|
+
narrativeTemplateKeys: configWithDefaults.narrativeTemplateKeys,
|
|
741
|
+
sceneDescription: configWithDefaults.sceneDescription,
|
|
742
|
+
respondModes: configWithDefaults.respondModes,
|
|
743
|
+
useResponseLifecycleContract: configWithDefaults.useResponseLifecycleContract,
|
|
744
|
+
invocationMetadata: configWithDefaults.invocationMetadata,
|
|
523
745
|
debug: configWithDefaults.debug,
|
|
524
746
|
onMessage: (payload) => this._messageHandler.handleDataReceivedPublic(payload),
|
|
525
747
|
onError: (error) => this.emit("error", error),
|
|
@@ -539,8 +761,12 @@ export class ConvaiClient extends EventEmitter {
|
|
|
539
761
|
}
|
|
540
762
|
// Prepare request body with required parameters
|
|
541
763
|
const characterSessionIdToSend = configWithDefaults.characterSessionId ?? this._characterSessionId;
|
|
764
|
+
const isRoster = isRosterConfig(configWithDefaults);
|
|
765
|
+
const serializedCapabilities = serializeProtocolCapabilities(configWithDefaults.capabilities);
|
|
542
766
|
const requestBody = {
|
|
543
|
-
|
|
767
|
+
...(isRoster
|
|
768
|
+
? buildRosterConnectFields(configWithDefaults)
|
|
769
|
+
: { character_id: configWithDefaults.characterReference }),
|
|
544
770
|
...(stateOfMind ? { state_of_mind: stateOfMind } : {}),
|
|
545
771
|
...(configWithDefaults.endUserId && {
|
|
546
772
|
end_user_id: configWithDefaults.endUserId,
|
|
@@ -549,9 +775,11 @@ export class ConvaiClient extends EventEmitter {
|
|
|
549
775
|
end_user_metadata: configWithDefaults.endUserMetadata,
|
|
550
776
|
}),
|
|
551
777
|
transport: transportType,
|
|
778
|
+
...(serializedCapabilities && {
|
|
779
|
+
capabilities: serializedCapabilities,
|
|
780
|
+
}),
|
|
552
781
|
connection_type: connType,
|
|
553
782
|
...buildBlendshapeConnectConfig(configWithDefaults),
|
|
554
|
-
llm_provider: "dynamic",
|
|
555
783
|
...(buildEmotionConnectConfig(configWithDefaults) && {
|
|
556
784
|
emotion_config: buildEmotionConnectConfig(configWithDefaults),
|
|
557
785
|
}),
|
|
@@ -562,10 +790,20 @@ export class ConvaiClient extends EventEmitter {
|
|
|
562
790
|
...(effectiveVisionInputConfig && {
|
|
563
791
|
vision_input_config: serializeVisionInputConfig(effectiveVisionInputConfig),
|
|
564
792
|
}),
|
|
793
|
+
...(configWithDefaults.visionPromptMode !== undefined && {
|
|
794
|
+
vision_prompt_mode: configWithDefaults.visionPromptMode,
|
|
795
|
+
}),
|
|
565
796
|
...(configWithDefaults.respondModes && {
|
|
566
797
|
respond_modes: this.serializeRespondModes(configWithDefaults.respondModes),
|
|
567
798
|
}),
|
|
568
|
-
...(
|
|
799
|
+
...(serializedCapabilities && { capabilities: serializedCapabilities }),
|
|
800
|
+
...(configWithDefaults.sceneDescription && {
|
|
801
|
+
scene_description: configWithDefaults.sceneDescription,
|
|
802
|
+
}),
|
|
803
|
+
// A roster room owns its members' sessions; the singular resume id is
|
|
804
|
+
// a compatibility mirror and must not go back on the wire.
|
|
805
|
+
...(!isRoster &&
|
|
806
|
+
characterSessionIdToSend &&
|
|
569
807
|
characterSessionIdToSend !== "-1" && {
|
|
570
808
|
character_session_id: characterSessionIdToSend,
|
|
571
809
|
}),
|
|
@@ -587,6 +825,12 @@ export class ConvaiClient extends EventEmitter {
|
|
|
587
825
|
...(configWithDefaults.debug !== undefined && {
|
|
588
826
|
debug: configWithDefaults.debug,
|
|
589
827
|
}),
|
|
828
|
+
...(configWithDefaults.preserveLinksInOutput !== undefined && {
|
|
829
|
+
preserve_links_in_output: configWithDefaults.preserveLinksInOutput,
|
|
830
|
+
}),
|
|
831
|
+
...(configWithDefaults.useResponseLifecycleContract !== undefined && {
|
|
832
|
+
use_response_lifecycle_contract: configWithDefaults.useResponseLifecycleContract,
|
|
833
|
+
}),
|
|
590
834
|
invocation_metadata: {
|
|
591
835
|
source: configWithDefaults.invocationMetadata?.source ?? "web_sdk",
|
|
592
836
|
client_version: configWithDefaults.invocationMetadata?.clientVersion ??
|
|
@@ -605,41 +849,62 @@ export class ConvaiClient extends EventEmitter {
|
|
|
605
849
|
else if (configWithDefaults.apiKey) {
|
|
606
850
|
connectHeaders["X-API-Key"] = configWithDefaults.apiKey;
|
|
607
851
|
}
|
|
608
|
-
// HTTP POST /connect — same request for both transports
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
const
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
852
|
+
// HTTP POST /connect — same request for both transports. A roster room
|
|
853
|
+
// may still be provisioning from an identical concurrent create, which
|
|
854
|
+
// the runtime answers with a retryable 409; the wrapper replays the
|
|
855
|
+
// identical payload and is a straight pass-through otherwise.
|
|
856
|
+
const connectOnce = async () => {
|
|
857
|
+
const response = await fetch(`${configWithDefaults.url}/connect`, {
|
|
858
|
+
method: "POST",
|
|
859
|
+
headers: connectHeaders,
|
|
860
|
+
body: JSON.stringify(requestBody),
|
|
861
|
+
});
|
|
862
|
+
if (!response.ok) {
|
|
863
|
+
const errorText = await response.text();
|
|
864
|
+
let errorMessage = `HTTP ${response.status}`;
|
|
865
|
+
try {
|
|
866
|
+
const errorData = JSON.parse(errorText);
|
|
867
|
+
const raw = errorData.message ?? errorData.error ?? errorData.detail;
|
|
868
|
+
if (typeof raw === "string") {
|
|
869
|
+
errorMessage = raw;
|
|
870
|
+
}
|
|
871
|
+
else if (Array.isArray(raw)) {
|
|
872
|
+
// FastAPI/Pydantic 422 — detail is an array of validation error objects
|
|
873
|
+
errorMessage = raw
|
|
874
|
+
.map((e) => [e.loc?.join("."), e.msg].filter(Boolean).join(": "))
|
|
875
|
+
.join("; ") || errorMessage;
|
|
876
|
+
}
|
|
877
|
+
else if (raw != null) {
|
|
878
|
+
errorMessage = JSON.stringify(raw);
|
|
879
|
+
}
|
|
880
|
+
else if (errorText) {
|
|
881
|
+
errorMessage = `${errorMessage}: ${errorText}`;
|
|
882
|
+
}
|
|
628
883
|
}
|
|
629
|
-
|
|
630
|
-
|
|
884
|
+
catch {
|
|
885
|
+
if (errorText)
|
|
886
|
+
errorMessage = `${errorMessage}: ${errorText}`;
|
|
631
887
|
}
|
|
632
|
-
|
|
633
|
-
|
|
888
|
+
// A roster room's failures are named and machine-actionable, and the
|
|
889
|
+
// trace id is what backend support correlates on, so they get a typed
|
|
890
|
+
// error. Single-character behaviour is unchanged.
|
|
891
|
+
if (isRoster) {
|
|
892
|
+
let parsed = errorText;
|
|
893
|
+
try {
|
|
894
|
+
parsed = JSON.parse(errorText);
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
897
|
+
/* keep the raw text as the detail */
|
|
898
|
+
}
|
|
899
|
+
throw roomErrorFromResponse(response.status, parsed);
|
|
634
900
|
}
|
|
901
|
+
throw new Error(errorMessage);
|
|
635
902
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
}
|
|
642
|
-
const connectionData = await response.json();
|
|
903
|
+
return await response.json();
|
|
904
|
+
};
|
|
905
|
+
const connectionData = isRoster
|
|
906
|
+
? await this._withProvisioningRetry(configWithDefaults, connectOnce)
|
|
907
|
+
: await connectOnce();
|
|
643
908
|
return await this.consumeConnectionData(connectionData, configWithDefaults);
|
|
644
909
|
}
|
|
645
910
|
catch (error) {
|
|
@@ -672,6 +937,10 @@ export class ConvaiClient extends EventEmitter {
|
|
|
672
937
|
* await client.connectWithConnectionData(data)
|
|
673
938
|
*/
|
|
674
939
|
async connectWithConnectionData(data, config) {
|
|
940
|
+
// A client can be reused after a published session. This is a normal
|
|
941
|
+
// credentialed/proxy connection path, so never leave published-only UI
|
|
942
|
+
// semantics (fresh grant requirement, text-only controls) enabled.
|
|
943
|
+
this._isPublishedChatSession = false;
|
|
675
944
|
const finalConfig = config || this._storedConfig;
|
|
676
945
|
if (!finalConfig) {
|
|
677
946
|
throw new Error("No configuration provided. Pass config to connectWithConnectionData() or store it in the client");
|
|
@@ -703,6 +972,274 @@ export class ConvaiClient extends EventEmitter {
|
|
|
703
972
|
throw error;
|
|
704
973
|
}
|
|
705
974
|
}
|
|
975
|
+
/** Correlate roster acks back to the promise that issued the command. */
|
|
976
|
+
_handleRosterServerResponse(response) {
|
|
977
|
+
const eventType = response.event_type;
|
|
978
|
+
if (eventType !== "interaction-target" &&
|
|
979
|
+
eventType !== "character-roster-update") {
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
const extras = response.extras ?? null;
|
|
983
|
+
const commandId = extras?.command_id;
|
|
984
|
+
const pending = commandId
|
|
985
|
+
? this._pendingRosterCommands.get(commandId)
|
|
986
|
+
: undefined;
|
|
987
|
+
if (response.status === "error") {
|
|
988
|
+
// Adopt the server's epochs even from a rejection -- a stale-epoch error
|
|
989
|
+
// carries the authoritative value, and that is what makes a retry
|
|
990
|
+
// meaningful.
|
|
991
|
+
this._roster.adoptEpochs(extras);
|
|
992
|
+
this._pushRosterState();
|
|
993
|
+
if (pending && commandId) {
|
|
994
|
+
this._pendingRosterCommands.delete(commandId);
|
|
995
|
+
clearTimeout(pending.timer);
|
|
996
|
+
pending.reject(Object.assign(new Error(response.message ?? `${eventType} was rejected`), { code: extras?.code ?? null, extras }));
|
|
997
|
+
}
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
// Apply the change whether or not this client asked for it: another
|
|
1001
|
+
// participant's switch reaches us through the same event.
|
|
1002
|
+
let change;
|
|
1003
|
+
if (eventType === "interaction-target") {
|
|
1004
|
+
change = this._roster.applyInteractionTarget(extras);
|
|
1005
|
+
this._pushRosterState();
|
|
1006
|
+
this.emit("activeCharacterChange", change);
|
|
1007
|
+
}
|
|
1008
|
+
else {
|
|
1009
|
+
change = this._roster.applyRosterUpdate(extras);
|
|
1010
|
+
this._pushRosterState();
|
|
1011
|
+
this.emit("rosterChange", change);
|
|
1012
|
+
}
|
|
1013
|
+
if (pending && commandId) {
|
|
1014
|
+
this._pendingRosterCommands.delete(commandId);
|
|
1015
|
+
clearTimeout(pending.timer);
|
|
1016
|
+
pending.resolve(change);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Settle every in-flight roster command.
|
|
1021
|
+
*
|
|
1022
|
+
* Without this a caller who disconnects mid-command keeps a live 10s timer
|
|
1023
|
+
* and gets a rejection long after the session ended -- an unhandled rejection
|
|
1024
|
+
* if they stopped awaiting when they tore the client down. The roster is
|
|
1025
|
+
* reset alongside, so the command could not be applied meaningfully anyway.
|
|
1026
|
+
*/
|
|
1027
|
+
_failPendingRosterCommands(reason) {
|
|
1028
|
+
for (const [, pending] of this._pendingRosterCommands) {
|
|
1029
|
+
clearTimeout(pending.timer);
|
|
1030
|
+
pending.reject(new Error(reason));
|
|
1031
|
+
}
|
|
1032
|
+
this._pendingRosterCommands.clear();
|
|
1033
|
+
}
|
|
1034
|
+
_pushRosterState() {
|
|
1035
|
+
this.updateState({
|
|
1036
|
+
characters: this._roster.characters,
|
|
1037
|
+
activeMembershipId: this._roster.activeMembershipId,
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
/** Publish a roster command and settle when its ack arrives. */
|
|
1041
|
+
_sendRosterCommand(type, build) {
|
|
1042
|
+
if (!this._roster.isMultiCharacter) {
|
|
1043
|
+
return Promise.reject(new Error(`Cannot send ${type}: this session was not created with a characters array`));
|
|
1044
|
+
}
|
|
1045
|
+
if (!this.isTransportReady()) {
|
|
1046
|
+
return Promise.reject(new Error(`Cannot send ${type}: not connected`));
|
|
1047
|
+
}
|
|
1048
|
+
const commandId = `${type}-${++this._rosterCommandSeq}-${Date.now()}`;
|
|
1049
|
+
return new Promise((resolve, reject) => {
|
|
1050
|
+
const timer = setTimeout(() => {
|
|
1051
|
+
this._pendingRosterCommands.delete(commandId);
|
|
1052
|
+
reject(new Error(`${type} was not acknowledged within ${ConvaiClient.ROSTER_COMMAND_TIMEOUT_MS}ms. ` +
|
|
1053
|
+
`The outcome is unknown -- acks can be suppressed as duplicates. ` +
|
|
1054
|
+
`Reconcile from the next authoritative room state before sending a conflicting command.`));
|
|
1055
|
+
}, ConvaiClient.ROSTER_COMMAND_TIMEOUT_MS);
|
|
1056
|
+
this._pendingRosterCommands.set(commandId, { resolve, reject, timer });
|
|
1057
|
+
this.publishMessage(type, build(commandId), commandId);
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Route subsequent user turns to a different character instance.
|
|
1062
|
+
*
|
|
1063
|
+
* Pass null to close every gate, so no member consumes the user until a
|
|
1064
|
+
* target is set again.
|
|
1065
|
+
*/
|
|
1066
|
+
async setInteractionTarget(membershipId) {
|
|
1067
|
+
if (!this._roster.isMultiCharacter) {
|
|
1068
|
+
throw new Error("Cannot send interaction-target: this session was not created with a characters array");
|
|
1069
|
+
}
|
|
1070
|
+
if (membershipId !== null) {
|
|
1071
|
+
if (!membershipId) {
|
|
1072
|
+
throw new Error("setInteractionTarget requires a membershipId, or null to close all gates");
|
|
1073
|
+
}
|
|
1074
|
+
const instance = this._roster.find(membershipId);
|
|
1075
|
+
if (this._roster.isMultiCharacter && !instance) {
|
|
1076
|
+
throw new Error(`Unknown membershipId "${membershipId}". Address instances by membershipId, not characterId.`);
|
|
1077
|
+
}
|
|
1078
|
+
if (instance && !instance.isReady) {
|
|
1079
|
+
throw new Error(`Membership "${membershipId}" has not sent bot-ready yet; the runtime refuses an unready target`);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
const send = () => this._sendRosterCommand("interaction-target", (commandId) => this._roster.buildInteractionTarget(membershipId, commandId));
|
|
1083
|
+
try {
|
|
1084
|
+
return (await send());
|
|
1085
|
+
}
|
|
1086
|
+
catch (error) {
|
|
1087
|
+
// Another participant switched first. The roster adopted the server's
|
|
1088
|
+
// epoch when the rejection arrived, so exactly one retry is now fenced
|
|
1089
|
+
// correctly; a second would mean we are losing a race we should report.
|
|
1090
|
+
if (error?.code !== "stale_route_epoch")
|
|
1091
|
+
throw error;
|
|
1092
|
+
return (await send());
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
/** Add or remove character instances on a live room, without reconnecting. */
|
|
1096
|
+
async updateCharacterRoster(options) {
|
|
1097
|
+
// Report the wrong-session-shape first. Otherwise a single-character
|
|
1098
|
+
// client, whose roster is empty, trips the "cannot be emptied" rule below
|
|
1099
|
+
// and gets an error about a room it never had.
|
|
1100
|
+
if (!this._roster.isMultiCharacter) {
|
|
1101
|
+
throw new Error("Cannot send character-roster-update: this session was not created with a characters array");
|
|
1102
|
+
}
|
|
1103
|
+
const add = options?.add ?? [];
|
|
1104
|
+
const remove = options?.remove ?? [];
|
|
1105
|
+
if (add.length === 0 && remove.length === 0) {
|
|
1106
|
+
throw new Error("updateCharacterRoster requires at least one add or remove");
|
|
1107
|
+
}
|
|
1108
|
+
if (add.some((c) => !c?.characterId?.trim())) {
|
|
1109
|
+
throw new Error("Every entry in add requires a characterId");
|
|
1110
|
+
}
|
|
1111
|
+
if (remove.length && remove.length >= this._roster.characters.length && add.length === 0) {
|
|
1112
|
+
throw new Error("A roster room cannot be emptied; add a replacement or keep one member");
|
|
1113
|
+
}
|
|
1114
|
+
// Removing the active member needs an explicit, ready replacement --
|
|
1115
|
+
// the runtime answers `replacement_target_required` otherwise, and
|
|
1116
|
+
// `replacement_target_unavailable` for one that has not sent bot-ready.
|
|
1117
|
+
const active = this._roster.activeMembershipId;
|
|
1118
|
+
const effective = !options.replacementTarget && active && remove.includes(active)
|
|
1119
|
+
? { ...options, replacementTarget: this._roster.pickReplacement(remove) }
|
|
1120
|
+
: options;
|
|
1121
|
+
return (await this._sendRosterCommand("character-roster-update", (commandId) => this._roster.buildRosterUpdate(effective, commandId)));
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Run a room request, retrying only the conflicts that mean "still working".
|
|
1125
|
+
*
|
|
1126
|
+
* The runtime answers a create that collides with an in-flight identical
|
|
1127
|
+
* create with 409 `ROSTER_PROVISIONING_IN_PROGRESS`, and the documented
|
|
1128
|
+
* recovery is to retry the same key with bounded backoff. Left to callers,
|
|
1129
|
+
* every integration reimplements that, usually without a cap. The payload is
|
|
1130
|
+
* replayed verbatim -- changing it would turn the retry into a fingerprint
|
|
1131
|
+
* mismatch.
|
|
1132
|
+
*/
|
|
1133
|
+
async _withProvisioningRetry(config, run) {
|
|
1134
|
+
const setting = config.rosterProvisioningRetry;
|
|
1135
|
+
if (setting === false)
|
|
1136
|
+
return run();
|
|
1137
|
+
const attempts = Math.max(1, setting?.attempts ?? ConvaiClient.ROSTER_RETRY_ATTEMPTS);
|
|
1138
|
+
const initialDelayMs = Math.max(0, setting?.initialDelayMs ?? ConvaiClient.ROSTER_RETRY_INITIAL_MS);
|
|
1139
|
+
let lastError;
|
|
1140
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
1141
|
+
try {
|
|
1142
|
+
return await run();
|
|
1143
|
+
}
|
|
1144
|
+
catch (error) {
|
|
1145
|
+
lastError = error;
|
|
1146
|
+
const retryable = error instanceof ConvaiRoomError && error.isRetryable;
|
|
1147
|
+
if (!retryable || attempt === attempts - 1)
|
|
1148
|
+
throw error;
|
|
1149
|
+
// Jitter so a lobby of clients waking on the same room does not
|
|
1150
|
+
// retry in lockstep.
|
|
1151
|
+
const base = initialDelayMs * 2 ** attempt;
|
|
1152
|
+
const jittered = base * (0.8 + Math.random() * 0.4);
|
|
1153
|
+
await new Promise((resolve) => setTimeout(resolve, jittered));
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
throw lastError;
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Join a multi-character room that already exists.
|
|
1160
|
+
*
|
|
1161
|
+
* A join supplies a locator and the joining human, and nothing else: the
|
|
1162
|
+
* roster is server-owned, so resending topology is rejected. It never spawns,
|
|
1163
|
+
* removes or repairs a character -- only room creation does that.
|
|
1164
|
+
*
|
|
1165
|
+
* Capacity counts distinct speakers derived from `endUserId`, and defaults to
|
|
1166
|
+
* one, so raise `maxNumParticipants` at creation before expecting a second
|
|
1167
|
+
* human to get in.
|
|
1168
|
+
*
|
|
1169
|
+
* @example
|
|
1170
|
+
* ```ts
|
|
1171
|
+
* const client = new ConvaiClient({ apiKey: 'YOUR_API_KEY' })
|
|
1172
|
+
* await client.joinRoom({
|
|
1173
|
+
* roomSessionId: 'the-room-session-id',
|
|
1174
|
+
* endUserId: 'player-99',
|
|
1175
|
+
* })
|
|
1176
|
+
* ```
|
|
1177
|
+
*/
|
|
1178
|
+
async joinRoom(options) {
|
|
1179
|
+
validateJoinOptions(options);
|
|
1180
|
+
// joinRoom is a credentialed Workspace connection, not a publication.
|
|
1181
|
+
// Reset this before further validation so a rejected normal attempt also
|
|
1182
|
+
// cannot leave a reused client in published-session mode.
|
|
1183
|
+
this._isPublishedChatSession = false;
|
|
1184
|
+
const stored = this._storedConfig ?? {};
|
|
1185
|
+
const url = stored.url ?? "https://realtime-api.convai.com";
|
|
1186
|
+
if (!stored.apiKey && !stored.authToken) {
|
|
1187
|
+
throw new Error("Either apiKey or authToken is required to join a room");
|
|
1188
|
+
}
|
|
1189
|
+
this._logRtviMessages = stored.logRtviMessages !== false;
|
|
1190
|
+
this._messageHandler.setRtviMessageLogging(this._logRtviMessages);
|
|
1191
|
+
this.updateState({ isConnecting: true });
|
|
1192
|
+
try {
|
|
1193
|
+
const connectionData = await this._withProvisioningRetry(stored, async () => {
|
|
1194
|
+
const response = await fetch(`${url}/connect`, {
|
|
1195
|
+
method: "POST",
|
|
1196
|
+
headers: {
|
|
1197
|
+
"Content-Type": "application/json",
|
|
1198
|
+
...(stored.apiKey
|
|
1199
|
+
? { "X-API-Key": stored.apiKey }
|
|
1200
|
+
: { "API-AUTH-TOKEN": stored.authToken }),
|
|
1201
|
+
},
|
|
1202
|
+
body: JSON.stringify(buildJoinRequestBody(options)),
|
|
1203
|
+
});
|
|
1204
|
+
if (!response.ok) {
|
|
1205
|
+
const text = await response.text();
|
|
1206
|
+
let parsed = text;
|
|
1207
|
+
try {
|
|
1208
|
+
parsed = JSON.parse(text);
|
|
1209
|
+
}
|
|
1210
|
+
catch {
|
|
1211
|
+
/* keep the raw text as the detail */
|
|
1212
|
+
}
|
|
1213
|
+
throw roomErrorFromResponse(response.status, parsed);
|
|
1214
|
+
}
|
|
1215
|
+
return (await response.json());
|
|
1216
|
+
});
|
|
1217
|
+
// The join response carries the persisted roster, so the joining client
|
|
1218
|
+
// learns the topology it never sent.
|
|
1219
|
+
const rosterCharacters = connectionData.characters;
|
|
1220
|
+
const initialCharacterId = rosterCharacters?.[0]?.character_id;
|
|
1221
|
+
const joinConfig = {
|
|
1222
|
+
...stored,
|
|
1223
|
+
characters: undefined,
|
|
1224
|
+
characterId: initialCharacterId ?? stored.characterId,
|
|
1225
|
+
endUserId: options.endUserId,
|
|
1226
|
+
...(options.endUserMetadata
|
|
1227
|
+
? { endUserMetadata: options.endUserMetadata }
|
|
1228
|
+
: {}),
|
|
1229
|
+
};
|
|
1230
|
+
this._storedConfig = joinConfig;
|
|
1231
|
+
const configWithDefaults = this.applyConfigDefaults(joinConfig, {
|
|
1232
|
+
requireCredential: false,
|
|
1233
|
+
});
|
|
1234
|
+
return await this.consumeConnectionData(connectionData, configWithDefaults);
|
|
1235
|
+
}
|
|
1236
|
+
catch (error) {
|
|
1237
|
+
await this.cleanupWebSocketSessionAfterConnectFailure();
|
|
1238
|
+
this.updateState({ isConnected: false, isConnecting: false });
|
|
1239
|
+
this.emit("error", error);
|
|
1240
|
+
throw error;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
706
1243
|
/**
|
|
707
1244
|
* Add default URL, apply RTVI-logging config, and validate that either an
|
|
708
1245
|
* API key or auth token plus a character ID are present.
|
|
@@ -727,9 +1264,27 @@ export class ConvaiClient extends EventEmitter {
|
|
|
727
1264
|
const hasApiKey = Boolean(configWithDefaults.apiKey);
|
|
728
1265
|
const hasAuthToken = Boolean(configWithDefaults.authToken);
|
|
729
1266
|
const missingCredential = requireCredential && !hasApiKey && !hasAuthToken;
|
|
730
|
-
|
|
1267
|
+
// Runs for every topology: the v2 protocol constraints rule out
|
|
1268
|
+
// sharedSessionKey and extra human capacity in single-character rooms too.
|
|
1269
|
+
validateProtocolTopology(configWithDefaults);
|
|
1270
|
+
const roster = isRosterConfig(configWithDefaults);
|
|
1271
|
+
if (missingCredential || (!roster && !configWithDefaults.characterId)) {
|
|
731
1272
|
throw new Error("Either apiKey or authToken is required, and characterId is required");
|
|
732
1273
|
}
|
|
1274
|
+
// A roster names its members individually; every roster rule is checked
|
|
1275
|
+
// before a request is built.
|
|
1276
|
+
if (roster) {
|
|
1277
|
+
validateRosterConfig(configWithDefaults);
|
|
1278
|
+
const initial = configWithDefaults.characters[0].characterId.trim();
|
|
1279
|
+
return {
|
|
1280
|
+
...configWithDefaults,
|
|
1281
|
+
// Singular readouts mirror characters[0], exactly as the wire's own
|
|
1282
|
+
// top-level session_id/character_session_id aliases do.
|
|
1283
|
+
characterId: initial,
|
|
1284
|
+
characterVersion: null,
|
|
1285
|
+
characterReference: initial,
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
733
1288
|
// Split the character id from its version selector. The bare UUID names
|
|
734
1289
|
// the character everywhere inside the SDK (memory, character info); the
|
|
735
1290
|
// joined reference is what the runtime receives.
|
|
@@ -790,6 +1345,24 @@ export class ConvaiClient extends EventEmitter {
|
|
|
790
1345
|
characterSessionId: connectionData.character_session_id,
|
|
791
1346
|
};
|
|
792
1347
|
}
|
|
1348
|
+
// The server answers a capability request with what it actually selected.
|
|
1349
|
+
// A silent downgrade would leave the client parsing v2 frames as v1 (or the
|
|
1350
|
+
// reverse), so each selection is validated and the model-output version the
|
|
1351
|
+
// message handler decodes is set from the server's answer, not the request.
|
|
1352
|
+
const selectedCapabilities = connectionData.capabilities ?? {};
|
|
1353
|
+
const requestedCapabilities = configWithDefaults.capabilities;
|
|
1354
|
+
validateActionProtocolSelection(requestedCapabilities?.actionProtocolVersion, selectedCapabilities.action_protocol_version);
|
|
1355
|
+
this._messageHandler.setModelOutputVersion(validateModelOutputVersionSelection(requestedCapabilities?.modelOutputVersion, selectedCapabilities.model_output_version));
|
|
1356
|
+
this._requestTraceId =
|
|
1357
|
+
connectionData.request_trace_id ?? null;
|
|
1358
|
+
// Seed the roster before the transport comes up, so the first bot-ready and
|
|
1359
|
+
// any early roster change already have instances to match against.
|
|
1360
|
+
if (this._roster.applyConnectResponse(connectionData)) {
|
|
1361
|
+
this.updateState({
|
|
1362
|
+
characters: this._roster.characters,
|
|
1363
|
+
activeMembershipId: this._roster.activeMembershipId,
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
793
1366
|
if (connectionData.end_user_id) {
|
|
794
1367
|
this._endUserId = connectionData.end_user_id;
|
|
795
1368
|
}
|
|
@@ -963,6 +1536,189 @@ export class ConvaiClient extends EventEmitter {
|
|
|
963
1536
|
}
|
|
964
1537
|
this.clearAllManagers();
|
|
965
1538
|
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Connect to a published text-chat revision without publisher credentials.
|
|
1541
|
+
*
|
|
1542
|
+
* The launch grant is single-use and is never retained for reconnect. This
|
|
1543
|
+
* path is LiveKit data-only: it does not enable microphone, camera, TTS,
|
|
1544
|
+
* video, or lipsync.
|
|
1545
|
+
*/
|
|
1546
|
+
async connectWithPublicationGrant(launchToken, options = {}) {
|
|
1547
|
+
if (typeof launchToken !== "string" ||
|
|
1548
|
+
launchToken.length < 32 ||
|
|
1549
|
+
launchToken.length > 512) {
|
|
1550
|
+
throw new Error("A valid publication launch token is required");
|
|
1551
|
+
}
|
|
1552
|
+
if (this._state.isConnected || this._state.isConnecting) {
|
|
1553
|
+
throw new Error("Disconnect the current session before connecting again");
|
|
1554
|
+
}
|
|
1555
|
+
const throwIfAborted = () => {
|
|
1556
|
+
if (options.signal?.aborted) {
|
|
1557
|
+
throw new DOMException("The operation was aborted", "AbortError");
|
|
1558
|
+
}
|
|
1559
|
+
};
|
|
1560
|
+
throwIfAborted();
|
|
1561
|
+
let abortDisconnect = null;
|
|
1562
|
+
const disconnectOnAbort = () => {
|
|
1563
|
+
abortDisconnect ?? (abortDisconnect = this.disconnect().catch(() => undefined));
|
|
1564
|
+
};
|
|
1565
|
+
const apiUrl = (options.url ?? "https://realtime-api.convai.com").replace(/\/+$/, "");
|
|
1566
|
+
const connectAttemptId = options.connectAttemptId ??
|
|
1567
|
+
(typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
1568
|
+
? `published_${crypto.randomUUID().replace(/-/g, "")}`
|
|
1569
|
+
: `published_${Date.now()}_${Math.random().toString(36).slice(2)}`);
|
|
1570
|
+
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(connectAttemptId)) {
|
|
1571
|
+
throw new Error("A valid published chat connect attempt id is required");
|
|
1572
|
+
}
|
|
1573
|
+
this._storedConfig = null;
|
|
1574
|
+
this._isPublishedChatSession = true;
|
|
1575
|
+
this._requestTraceId = null;
|
|
1576
|
+
this._apiKey = null;
|
|
1577
|
+
this._authToken = null;
|
|
1578
|
+
this._characterId = null;
|
|
1579
|
+
this._characterSessionId = "-1";
|
|
1580
|
+
this._endUserId = null;
|
|
1581
|
+
this._endUserMetadata = null;
|
|
1582
|
+
this._requestTraceId = null;
|
|
1583
|
+
this._memoryManager = null;
|
|
1584
|
+
this._connectionType = "text";
|
|
1585
|
+
this._activeTransport = "livekit";
|
|
1586
|
+
// Published chat does not negotiate protocol capabilities. A client may
|
|
1587
|
+
// have previously connected to a v2 character, so restore the default v1
|
|
1588
|
+
// handling before accepting this text-only session's legacy envelopes.
|
|
1589
|
+
this._messageHandler.setModelOutputVersion(1);
|
|
1590
|
+
this.updateState({ isConnecting: true });
|
|
1591
|
+
try {
|
|
1592
|
+
options.signal?.addEventListener("abort", disconnectOnAbort, {
|
|
1593
|
+
once: true,
|
|
1594
|
+
});
|
|
1595
|
+
const exchangeGrant = () => fetch(`${apiUrl}/published-chat/connect`, {
|
|
1596
|
+
method: "POST",
|
|
1597
|
+
// A published launch token is bearer-like. Never include any ambient
|
|
1598
|
+
// cookies when the host happens to be same-origin with Core.
|
|
1599
|
+
credentials: "omit",
|
|
1600
|
+
headers: { "Content-Type": "application/json" },
|
|
1601
|
+
body: JSON.stringify({
|
|
1602
|
+
launch_token: launchToken,
|
|
1603
|
+
connect_attempt_id: connectAttemptId,
|
|
1604
|
+
}),
|
|
1605
|
+
signal: options.signal,
|
|
1606
|
+
});
|
|
1607
|
+
const readConnectionResponse = async (response, retryTransientFailure) => {
|
|
1608
|
+
if (!response.ok) {
|
|
1609
|
+
if (retryTransientFailure && response.status >= 500) {
|
|
1610
|
+
throw new RetryablePublishedChatExchangeError();
|
|
1611
|
+
}
|
|
1612
|
+
const errorText = await response.text();
|
|
1613
|
+
let errorMessage = `HTTP ${response.status}`;
|
|
1614
|
+
try {
|
|
1615
|
+
const errorData = JSON.parse(errorText);
|
|
1616
|
+
this._requestTraceId =
|
|
1617
|
+
errorData.request_trace_id ?? errorData.requestTraceId ?? null;
|
|
1618
|
+
const raw = errorData.message ?? errorData.error ?? errorData.detail;
|
|
1619
|
+
errorMessage =
|
|
1620
|
+
typeof raw === "string"
|
|
1621
|
+
? raw
|
|
1622
|
+
: raw == null
|
|
1623
|
+
? errorMessage
|
|
1624
|
+
: JSON.stringify(raw);
|
|
1625
|
+
}
|
|
1626
|
+
catch {
|
|
1627
|
+
if (errorText)
|
|
1628
|
+
errorMessage = `${errorMessage}: ${errorText}`;
|
|
1629
|
+
}
|
|
1630
|
+
throw new Error(errorMessage);
|
|
1631
|
+
}
|
|
1632
|
+
let connectionData;
|
|
1633
|
+
try {
|
|
1634
|
+
connectionData = await response.json();
|
|
1635
|
+
}
|
|
1636
|
+
catch {
|
|
1637
|
+
if (retryTransientFailure) {
|
|
1638
|
+
throw new RetryablePublishedChatExchangeError();
|
|
1639
|
+
}
|
|
1640
|
+
throw new Error("Published chat returned an invalid connection response");
|
|
1641
|
+
}
|
|
1642
|
+
throwIfAborted();
|
|
1643
|
+
if (typeof connectionData !== "object" ||
|
|
1644
|
+
connectionData === null ||
|
|
1645
|
+
typeof connectionData.room_url !== "string" ||
|
|
1646
|
+
typeof connectionData.token !== "string" ||
|
|
1647
|
+
typeof connectionData.character_session_id !==
|
|
1648
|
+
"string") {
|
|
1649
|
+
if (retryTransientFailure) {
|
|
1650
|
+
throw new RetryablePublishedChatExchangeError();
|
|
1651
|
+
}
|
|
1652
|
+
throw new Error("Published chat returned an invalid connection response");
|
|
1653
|
+
}
|
|
1654
|
+
return connectionData;
|
|
1655
|
+
};
|
|
1656
|
+
const requestConnection = async (retryTransientFailure) => {
|
|
1657
|
+
let response;
|
|
1658
|
+
try {
|
|
1659
|
+
response = await exchangeGrant();
|
|
1660
|
+
}
|
|
1661
|
+
catch (error) {
|
|
1662
|
+
throwIfAborted();
|
|
1663
|
+
if (retryTransientFailure) {
|
|
1664
|
+
throw new RetryablePublishedChatExchangeError();
|
|
1665
|
+
}
|
|
1666
|
+
throw error;
|
|
1667
|
+
}
|
|
1668
|
+
return readConnectionResponse(response, retryTransientFailure);
|
|
1669
|
+
};
|
|
1670
|
+
let connectionData;
|
|
1671
|
+
try {
|
|
1672
|
+
connectionData = await requestConnection(true);
|
|
1673
|
+
}
|
|
1674
|
+
catch (error) {
|
|
1675
|
+
throwIfAborted();
|
|
1676
|
+
if (!(error instanceof RetryablePublishedChatExchangeError)) {
|
|
1677
|
+
throw error;
|
|
1678
|
+
}
|
|
1679
|
+
// A response can be lost or corrupted after Core consumed the
|
|
1680
|
+
// single-use grant. Retry exactly once with the same attempt key;
|
|
1681
|
+
// Core/Character API replay that logical attempt instead of creating
|
|
1682
|
+
// a second session.
|
|
1683
|
+
connectionData = await requestConnection(false);
|
|
1684
|
+
}
|
|
1685
|
+
throwIfAborted();
|
|
1686
|
+
this._requestTraceId = connectionData.request_trace_id ?? null;
|
|
1687
|
+
this._characterSessionId = connectionData.character_session_id;
|
|
1688
|
+
this._endUserId = connectionData.end_user_id ?? null;
|
|
1689
|
+
this._endUserMetadata = connectionData.end_user_metadata ?? null;
|
|
1690
|
+
await this._room.connect(connectionData.room_url, connectionData.token, {
|
|
1691
|
+
rtcConfig: {
|
|
1692
|
+
// Hosted Core has TURN, so relay is the safe default. On-prem
|
|
1693
|
+
// deployments without TURN may explicitly allow host/srflx paths.
|
|
1694
|
+
iceTransportPolicy: options.iceTransportPolicy ?? "relay",
|
|
1695
|
+
},
|
|
1696
|
+
});
|
|
1697
|
+
throwIfAborted();
|
|
1698
|
+
this._audioManager.syncStateFromRoom({ emit: true });
|
|
1699
|
+
this._participantSid = this._room.localParticipant.sid;
|
|
1700
|
+
this.updateState({
|
|
1701
|
+
isConnected: true,
|
|
1702
|
+
isConnecting: false,
|
|
1703
|
+
endUserId: this._endUserId,
|
|
1704
|
+
endUserMetadata: this._endUserMetadata,
|
|
1705
|
+
});
|
|
1706
|
+
this.emit("connect");
|
|
1707
|
+
this._startClientReadyHandshake();
|
|
1708
|
+
}
|
|
1709
|
+
catch (error) {
|
|
1710
|
+
if (options.signal?.aborted) {
|
|
1711
|
+
abortDisconnect ?? (abortDisconnect = this.disconnect().catch(() => undefined));
|
|
1712
|
+
await abortDisconnect;
|
|
1713
|
+
}
|
|
1714
|
+
this.updateState({ isConnected: false, isConnecting: false });
|
|
1715
|
+
this.emit("error", error);
|
|
1716
|
+
throw error;
|
|
1717
|
+
}
|
|
1718
|
+
finally {
|
|
1719
|
+
options.signal?.removeEventListener("abort", disconnectOnAbort);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
966
1722
|
/**
|
|
967
1723
|
* RTVI client-ready handshake. Server-side gates bot-ready emission on
|
|
968
1724
|
* receipt of client-ready (and re-emits bot-ready on every client-ready),
|
|
@@ -1027,6 +1783,10 @@ export class ConvaiClient extends EventEmitter {
|
|
|
1027
1783
|
*/
|
|
1028
1784
|
async disconnect() {
|
|
1029
1785
|
this._stopClientReadyHandshake();
|
|
1786
|
+
// Unconditional: the branches below each depend on a transport being
|
|
1787
|
+
// matched, and none of them runs when there is nothing to tear down. A
|
|
1788
|
+
// command in flight must still be settled either way.
|
|
1789
|
+
this._failPendingRosterCommands("Disconnected before the roster command was acknowledged; its outcome is unknown");
|
|
1030
1790
|
if (this._activeTransport === "sse" && this._sseSession) {
|
|
1031
1791
|
await this._sseSession.disconnect();
|
|
1032
1792
|
this._connectionStateHandler.resetConnectionState();
|
|
@@ -1059,6 +1819,15 @@ export class ConvaiClient extends EventEmitter {
|
|
|
1059
1819
|
this.clearAllManagers();
|
|
1060
1820
|
}
|
|
1061
1821
|
}
|
|
1822
|
+
else if (this._state.isConnected || this._state.isConnecting) {
|
|
1823
|
+
// No transport matched, but the client believes it is (or is becoming)
|
|
1824
|
+
// connected -- a connect() that failed after flipping isConnecting, or
|
|
1825
|
+
// was torn down underneath us. Without this branch disconnect() was a
|
|
1826
|
+
// silent no-op here and the flags stayed stuck, so a UI bound to
|
|
1827
|
+
// stateChange kept showing a session that did not exist.
|
|
1828
|
+
this._connectionStateHandler.resetConnectionState();
|
|
1829
|
+
this.clearAllManagers();
|
|
1830
|
+
}
|
|
1062
1831
|
}
|
|
1063
1832
|
/**
|
|
1064
1833
|
* Clear all manager states
|
|
@@ -1067,6 +1836,8 @@ export class ConvaiClient extends EventEmitter {
|
|
|
1067
1836
|
this._connectionType = null;
|
|
1068
1837
|
this._activeTransport = null;
|
|
1069
1838
|
this._wsSession = null;
|
|
1839
|
+
this._failPendingRosterCommands("Disconnected before the roster command was acknowledged; its outcome is unknown");
|
|
1840
|
+
this._roster.reset();
|
|
1070
1841
|
this._sseSession = null;
|
|
1071
1842
|
this._apiKey = null;
|
|
1072
1843
|
this._authToken = null;
|
|
@@ -1095,6 +1866,13 @@ export class ConvaiClient extends EventEmitter {
|
|
|
1095
1866
|
* Reset the session ID to start a new conversation
|
|
1096
1867
|
*/
|
|
1097
1868
|
resetSession() {
|
|
1869
|
+
// In a roster room the singular session id is a compatibility mirror of
|
|
1870
|
+
// characters[0], and character sessions are server-owned -- there is no
|
|
1871
|
+
// client-side reset for them. Throwing names the situation; a silent no-op
|
|
1872
|
+
// would look like it worked.
|
|
1873
|
+
if (this._roster.isMultiCharacter) {
|
|
1874
|
+
throw new Error("resetSession() is not supported in a multi-character room; character sessions are server-owned");
|
|
1875
|
+
}
|
|
1098
1876
|
this._characterSessionId = "-1";
|
|
1099
1877
|
this.emit("characterSessionId", "-1");
|
|
1100
1878
|
this._messageHandler.reset();
|
|
@@ -1136,15 +1914,28 @@ export class ConvaiClient extends EventEmitter {
|
|
|
1136
1914
|
const stateOfMind = normalizeStateOfMind(options.stateOfMind) ?? null;
|
|
1137
1915
|
if (stateOfMind)
|
|
1138
1916
|
this._stateOfMind = stateOfMind;
|
|
1139
|
-
if (
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1917
|
+
if (this._activeTransport === "sse") {
|
|
1918
|
+
const requestedDynamicInfo = options.dynamicInfo?.trim();
|
|
1919
|
+
if (requestedDynamicInfo) {
|
|
1920
|
+
this._sseSession?.updateDynamicInfo(requestedDynamicInfo);
|
|
1921
|
+
}
|
|
1922
|
+
const messages = [];
|
|
1923
|
+
const dynamicInfo = this._sseSession?.getDynamicInfo()?.trim();
|
|
1924
|
+
if (dynamicInfo) {
|
|
1925
|
+
messages.push({
|
|
1926
|
+
type: "update-dynamic-info",
|
|
1927
|
+
data: { dynamic_info: { text: dynamicInfo } },
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
if (stateOfMind) {
|
|
1931
|
+
messages.push({ type: "update-emotion", data: { state_of_mind: stateOfMind } });
|
|
1932
|
+
}
|
|
1933
|
+
messages.push({
|
|
1934
|
+
type: "user_text_message",
|
|
1935
|
+
data: userTextMessage,
|
|
1936
|
+
...(logicalTurnId !== undefined ? { id: logicalTurnId } : {}),
|
|
1937
|
+
});
|
|
1938
|
+
this._sseSession?.sendMessages(messages);
|
|
1148
1939
|
}
|
|
1149
1940
|
else {
|
|
1150
1941
|
if (stateOfMind) {
|
|
@@ -1155,11 +1946,167 @@ export class ConvaiClient extends EventEmitter {
|
|
|
1155
1946
|
}
|
|
1156
1947
|
this.publishMessage("user_text_message", userTextMessage, logicalTurnId);
|
|
1157
1948
|
}
|
|
1949
|
+
// The submitted line is appended locally on every transport, so it shows
|
|
1950
|
+
// without waiting for a server echo. Sessions that do echo it back as a
|
|
1951
|
+
// final user-transcription are deduplicated in the message handler.
|
|
1952
|
+
this._messageHandler.addUserTextMessage(text);
|
|
1158
1953
|
}
|
|
1159
1954
|
catch (error) {
|
|
1160
1955
|
throw error;
|
|
1161
1956
|
}
|
|
1162
1957
|
}
|
|
1958
|
+
/**
|
|
1959
|
+
* Return a correlated terminal result for a client-executed tool call.
|
|
1960
|
+
*/
|
|
1961
|
+
sendActionResult(result) {
|
|
1962
|
+
const payload = this.buildActionResultPayload(result);
|
|
1963
|
+
this.publishMessage("action-result", payload);
|
|
1964
|
+
}
|
|
1965
|
+
/**
|
|
1966
|
+
* Publish one correlated terminal result and wait for Core's exact
|
|
1967
|
+
* `server-response` acknowledgement. This method performs one publication;
|
|
1968
|
+
* callers own any idempotent retry policy.
|
|
1969
|
+
*/
|
|
1970
|
+
async sendActionResultAndWait(result, options = {}) {
|
|
1971
|
+
const timeoutMs = options.timeoutMs ?? ConvaiClient.ACTION_RESULT_ACK_TIMEOUT_MS;
|
|
1972
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
1973
|
+
throw new Error("Action result acknowledgement timeout must be greater than zero");
|
|
1974
|
+
}
|
|
1975
|
+
if (options.signal?.aborted) {
|
|
1976
|
+
throw new ActionResultAckError("Action result acknowledgement was aborted", {
|
|
1977
|
+
reason: "aborted",
|
|
1978
|
+
toolCallId: result.id,
|
|
1979
|
+
});
|
|
1980
|
+
}
|
|
1981
|
+
const payload = this.buildActionResultPayload(result);
|
|
1982
|
+
const toolCallId = payload.id;
|
|
1983
|
+
if (this._pendingActionResultAcks.has(toolCallId)) {
|
|
1984
|
+
throw new ActionResultAckError(`An action result acknowledgement is already pending for ${toolCallId}`, {
|
|
1985
|
+
reason: "duplicate_in_flight",
|
|
1986
|
+
toolCallId,
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
const reservation = Symbol(toolCallId);
|
|
1990
|
+
this._pendingActionResultAcks.set(toolCallId, reservation);
|
|
1991
|
+
return new Promise((resolve, reject) => {
|
|
1992
|
+
let settled = false;
|
|
1993
|
+
let timeout = null;
|
|
1994
|
+
let unsubscribe = null;
|
|
1995
|
+
const cleanup = () => {
|
|
1996
|
+
if (timeout !== null) {
|
|
1997
|
+
clearTimeout(timeout);
|
|
1998
|
+
timeout = null;
|
|
1999
|
+
}
|
|
2000
|
+
if (unsubscribe) {
|
|
2001
|
+
unsubscribe();
|
|
2002
|
+
unsubscribe = null;
|
|
2003
|
+
}
|
|
2004
|
+
options.signal?.removeEventListener("abort", handleAbort);
|
|
2005
|
+
if (this._pendingActionResultAcks.get(toolCallId) === reservation) {
|
|
2006
|
+
this._pendingActionResultAcks.delete(toolCallId);
|
|
2007
|
+
}
|
|
2008
|
+
};
|
|
2009
|
+
const resolveOnce = (ack) => {
|
|
2010
|
+
if (settled)
|
|
2011
|
+
return;
|
|
2012
|
+
settled = true;
|
|
2013
|
+
cleanup();
|
|
2014
|
+
resolve(ack);
|
|
2015
|
+
};
|
|
2016
|
+
const rejectOnce = (error) => {
|
|
2017
|
+
if (settled)
|
|
2018
|
+
return;
|
|
2019
|
+
settled = true;
|
|
2020
|
+
cleanup();
|
|
2021
|
+
reject(error);
|
|
2022
|
+
};
|
|
2023
|
+
const handleAbort = () => {
|
|
2024
|
+
rejectOnce(new ActionResultAckError("Action result acknowledgement was aborted", {
|
|
2025
|
+
reason: "aborted",
|
|
2026
|
+
toolCallId,
|
|
2027
|
+
}));
|
|
2028
|
+
};
|
|
2029
|
+
const handleServerResponse = (response) => {
|
|
2030
|
+
if (!response || typeof response !== "object")
|
|
2031
|
+
return;
|
|
2032
|
+
const candidate = response;
|
|
2033
|
+
if (candidate.event_type !== "action-result")
|
|
2034
|
+
return;
|
|
2035
|
+
const extras = candidate.extras;
|
|
2036
|
+
if (!extras || extras.tool_call_id !== toolCallId)
|
|
2037
|
+
return;
|
|
2038
|
+
const message = typeof candidate.message === "string" ? candidate.message : null;
|
|
2039
|
+
if (candidate.status === "success") {
|
|
2040
|
+
resolveOnce({
|
|
2041
|
+
toolCallId,
|
|
2042
|
+
status: "success",
|
|
2043
|
+
message,
|
|
2044
|
+
idempotent: extras.idempotent === true,
|
|
2045
|
+
});
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
if (candidate.status !== "error")
|
|
2049
|
+
return;
|
|
2050
|
+
const errorCode = typeof extras.error_code === "string" ? extras.error_code : undefined;
|
|
2051
|
+
rejectOnce(new ActionResultAckError(message ?? errorCode ?? "Core rejected the action result", {
|
|
2052
|
+
reason: "server_error",
|
|
2053
|
+
toolCallId,
|
|
2054
|
+
...(errorCode ? { errorCode } : {}),
|
|
2055
|
+
response: candidate,
|
|
2056
|
+
}));
|
|
2057
|
+
};
|
|
2058
|
+
// Register every terminal observer before publication so a synchronous
|
|
2059
|
+
// WebSocket acknowledgement cannot outrun its waiter.
|
|
2060
|
+
unsubscribe = this.on("serverResponse", handleServerResponse);
|
|
2061
|
+
options.signal?.addEventListener("abort", handleAbort, { once: true });
|
|
2062
|
+
timeout = setTimeout(() => {
|
|
2063
|
+
rejectOnce(new ActionResultAckError(`Timed out waiting for action result acknowledgement for ${toolCallId}`, {
|
|
2064
|
+
reason: "timeout",
|
|
2065
|
+
toolCallId,
|
|
2066
|
+
}));
|
|
2067
|
+
}, timeoutMs);
|
|
2068
|
+
void this.publishMessageAndWaitForLocalPublication("action-result", payload).catch((cause) => {
|
|
2069
|
+
const detail = cause instanceof Error ? `: ${cause.message}` : "";
|
|
2070
|
+
rejectOnce(new ActionResultAckError(`Failed to publish action result ${toolCallId}${detail}`, {
|
|
2071
|
+
reason: "publication_error",
|
|
2072
|
+
toolCallId,
|
|
2073
|
+
cause,
|
|
2074
|
+
}));
|
|
2075
|
+
});
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
buildActionResultPayload(result) {
|
|
2079
|
+
if (!this.isTransportReady()) {
|
|
2080
|
+
throw new Error("Cannot send an action result before the session is ready");
|
|
2081
|
+
}
|
|
2082
|
+
if (!result.id.trim())
|
|
2083
|
+
throw new Error("Action result id is required");
|
|
2084
|
+
if (result.status !== "completed" &&
|
|
2085
|
+
result.status !== "error" &&
|
|
2086
|
+
result.status !== "cancelled") {
|
|
2087
|
+
throw new Error("Unsupported action result status");
|
|
2088
|
+
}
|
|
2089
|
+
if (result.status === "completed" && result.error !== undefined) {
|
|
2090
|
+
throw new Error("Completed action results cannot include error");
|
|
2091
|
+
}
|
|
2092
|
+
if (result.output !== undefined && !isJsonValue(result.output)) {
|
|
2093
|
+
throw new Error("Action result output must contain only finite JSON values");
|
|
2094
|
+
}
|
|
2095
|
+
if (result.error !== undefined && !isJsonValue(result.error)) {
|
|
2096
|
+
throw new Error("Action result error must contain only finite JSON values");
|
|
2097
|
+
}
|
|
2098
|
+
const characterSessionId = result.characterSessionId ?? this._characterSessionId;
|
|
2099
|
+
return {
|
|
2100
|
+
id: result.id,
|
|
2101
|
+
status: result.status,
|
|
2102
|
+
...(result.output !== undefined && { output: result.output }),
|
|
2103
|
+
...(result.error !== undefined && { error: result.error }),
|
|
2104
|
+
...(characterSessionId &&
|
|
2105
|
+
characterSessionId !== "-1" && {
|
|
2106
|
+
character_session_id: characterSessionId,
|
|
2107
|
+
}),
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
1163
2110
|
/**
|
|
1164
2111
|
* Send a trigger message to invoke specific character actions
|
|
1165
2112
|
*/
|
|
@@ -1260,8 +2207,16 @@ export class ConvaiClient extends EventEmitter {
|
|
|
1260
2207
|
updateDynamicInfo(dynamicInfo) {
|
|
1261
2208
|
if (!this.isTransportReady() || !dynamicInfo?.trim())
|
|
1262
2209
|
return;
|
|
2210
|
+
const nextDynamicInfo = dynamicInfo.trim();
|
|
2211
|
+
if (this._storedConfig) {
|
|
2212
|
+
this._storedConfig = { ...this._storedConfig, dynamicInfo: nextDynamicInfo };
|
|
2213
|
+
}
|
|
2214
|
+
if (this._activeTransport === "sse" && this._sseSession) {
|
|
2215
|
+
this._sseSession.updateDynamicInfo(nextDynamicInfo);
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
1263
2218
|
this.publishMessage("update-dynamic-info", {
|
|
1264
|
-
dynamic_info: { text:
|
|
2219
|
+
dynamic_info: { text: nextDynamicInfo },
|
|
1265
2220
|
participant_sid: this._participantSid || this._room.localParticipant?.sid,
|
|
1266
2221
|
});
|
|
1267
2222
|
}
|
|
@@ -1309,6 +2264,22 @@ export class ConvaiClient extends EventEmitter {
|
|
|
1309
2264
|
if (shouldPreemptLipsync) {
|
|
1310
2265
|
this.startTrackedConversation(`[context-update:${options.mode ?? "append"}]`);
|
|
1311
2266
|
}
|
|
2267
|
+
if (this._activeTransport === "sse" && this._sseSession) {
|
|
2268
|
+
const data = {
|
|
2269
|
+
...(hasText && { text: options.text.trim() }),
|
|
2270
|
+
...(options.mode && { mode: options.mode }),
|
|
2271
|
+
...(options.run_llm && { run_llm: options.run_llm }),
|
|
2272
|
+
...(options.respond_mode && { respond_mode: options.respond_mode }),
|
|
2273
|
+
...(hasAttention && { current_attention_object: options.current_attention_object }),
|
|
2274
|
+
};
|
|
2275
|
+
if (options.run_llm === "false") {
|
|
2276
|
+
this._sseSession.queueMessage("context-update", data);
|
|
2277
|
+
}
|
|
2278
|
+
else {
|
|
2279
|
+
this._sseSession.sendMessage("context-update", data);
|
|
2280
|
+
}
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
1312
2283
|
this.publishMessage("context-update", {
|
|
1313
2284
|
...(hasText && { text: options.text.trim() }),
|
|
1314
2285
|
...(options.mode && { mode: options.mode }),
|
|
@@ -1370,6 +2341,8 @@ export class ConvaiClient extends EventEmitter {
|
|
|
1370
2341
|
}
|
|
1371
2342
|
if (!this.isTransportReady())
|
|
1372
2343
|
return;
|
|
2344
|
+
if (this._activeTransport === "sse")
|
|
2345
|
+
return;
|
|
1373
2346
|
this.publishMessage("update-emotion", {
|
|
1374
2347
|
state_of_mind: normalized,
|
|
1375
2348
|
participant_sid: this._participantSid || this._room.localParticipant?.sid,
|
|
@@ -1464,4 +2437,18 @@ export class ConvaiClient extends EventEmitter {
|
|
|
1464
2437
|
}
|
|
1465
2438
|
ConvaiClient.CLIENT_READY_RETRY_MS = 500;
|
|
1466
2439
|
ConvaiClient.CLIENT_READY_TIMEOUT_MS = 45000;
|
|
2440
|
+
ConvaiClient.ACTION_RESULT_ACK_TIMEOUT_MS = 10000;
|
|
2441
|
+
/**
|
|
2442
|
+
* How long to wait for a roster command's ack.
|
|
2443
|
+
*
|
|
2444
|
+
* A timeout is *not* a failure. Every character participant observes the
|
|
2445
|
+
* broadcast and duplicate forwards are response-suppressed after one actor
|
|
2446
|
+
* answers, so a lost ack is an expected outcome. The contract's rule is to
|
|
2447
|
+
* treat it as unknown and reconcile from the next authoritative snapshot,
|
|
2448
|
+
* which is what the rejection below tells the caller to do.
|
|
2449
|
+
*/
|
|
2450
|
+
ConvaiClient.ROSTER_COMMAND_TIMEOUT_MS = 10000;
|
|
2451
|
+
/** Default bounded retry while a roster room is still provisioning. */
|
|
2452
|
+
ConvaiClient.ROSTER_RETRY_ATTEMPTS = 4;
|
|
2453
|
+
ConvaiClient.ROSTER_RETRY_INITIAL_MS = 500;
|
|
1467
2454
|
//# sourceMappingURL=ConvaiClient.js.map
|