@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.
Files changed (110) hide show
  1. package/README.md +333 -13
  2. package/dist/core/CharacterRoster.d.ts +84 -0
  3. package/dist/core/CharacterRoster.d.ts.map +1 -0
  4. package/dist/core/CharacterRoster.js +348 -0
  5. package/dist/core/CharacterRoster.js.map +1 -0
  6. package/dist/core/ConvaiClient.d.ts +152 -2
  7. package/dist/core/ConvaiClient.d.ts.map +1 -1
  8. package/dist/core/ConvaiClient.js +1036 -49
  9. package/dist/core/ConvaiClient.js.map +1 -1
  10. package/dist/core/ConvaiRoomError.d.ts +48 -0
  11. package/dist/core/ConvaiRoomError.d.ts.map +1 -0
  12. package/dist/core/ConvaiRoomError.js +111 -0
  13. package/dist/core/ConvaiRoomError.js.map +1 -0
  14. package/dist/core/MessageHandler.d.ts +25 -0
  15. package/dist/core/MessageHandler.d.ts.map +1 -1
  16. package/dist/core/MessageHandler.js +431 -30
  17. package/dist/core/MessageHandler.js.map +1 -1
  18. package/dist/core/SSESession.d.ts +15 -0
  19. package/dist/core/SSESession.d.ts.map +1 -1
  20. package/dist/core/SSESession.js +92 -3
  21. package/dist/core/SSESession.js.map +1 -1
  22. package/dist/core/connectRequest.d.ts +38 -2
  23. package/dist/core/connectRequest.d.ts.map +1 -1
  24. package/dist/core/connectRequest.js +57 -0
  25. package/dist/core/connectRequest.js.map +1 -1
  26. package/dist/core/index.d.ts +6 -1
  27. package/dist/core/index.d.ts.map +1 -1
  28. package/dist/core/index.js +5 -1
  29. package/dist/core/index.js.map +1 -1
  30. package/dist/core/logicalTurn.d.ts +4 -1
  31. package/dist/core/logicalTurn.d.ts.map +1 -1
  32. package/dist/core/logicalTurn.js +35 -4
  33. package/dist/core/logicalTurn.js.map +1 -1
  34. package/dist/core/modelOutput.d.ts +40 -0
  35. package/dist/core/modelOutput.d.ts.map +1 -0
  36. package/dist/core/modelOutput.js +187 -0
  37. package/dist/core/modelOutput.js.map +1 -0
  38. package/dist/core/publishedChat.d.ts +9 -0
  39. package/dist/core/publishedChat.d.ts.map +1 -0
  40. package/dist/core/publishedChat.js +13 -0
  41. package/dist/core/publishedChat.js.map +1 -0
  42. package/dist/core/rosterRequest.d.ts +57 -0
  43. package/dist/core/rosterRequest.d.ts.map +1 -0
  44. package/dist/core/rosterRequest.js +210 -0
  45. package/dist/core/rosterRequest.js.map +1 -0
  46. package/dist/core/types.d.ts +667 -10
  47. package/dist/core/types.d.ts.map +1 -1
  48. package/dist/core/types.js.map +1 -1
  49. package/dist/embed/ConvaiChatElement.d.ts +27 -0
  50. package/dist/embed/ConvaiChatElement.d.ts.map +1 -0
  51. package/dist/embed/ConvaiChatElement.js +300 -0
  52. package/dist/embed/ConvaiChatElement.js.map +1 -0
  53. package/dist/embed/browser.d.ts +3 -0
  54. package/dist/embed/browser.d.ts.map +1 -0
  55. package/dist/embed/browser.js +5 -0
  56. package/dist/embed/browser.js.map +1 -0
  57. package/dist/embed/chat-embed-v1.js +100 -0
  58. package/dist/embed/chat-embed-v1.js.map +7 -0
  59. package/dist/embed/index.d.ts +9 -0
  60. package/dist/embed/index.d.ts.map +1 -0
  61. package/dist/embed/index.js +9 -0
  62. package/dist/embed/index.js.map +1 -0
  63. package/dist/embed/publicationApi.d.ts +23 -0
  64. package/dist/embed/publicationApi.d.ts.map +1 -0
  65. package/dist/embed/publicationApi.js +141 -0
  66. package/dist/embed/publicationApi.js.map +1 -0
  67. package/dist/react/components/ConvaiWidget.d.ts +11 -1
  68. package/dist/react/components/ConvaiWidget.d.ts.map +1 -1
  69. package/dist/react/components/ConvaiWidget.js +67 -11
  70. package/dist/react/components/ConvaiWidget.js.map +1 -1
  71. package/dist/react/components/rtc-widget/components/MarkdownRenderer.d.ts.map +1 -1
  72. package/dist/react/components/rtc-widget/components/MarkdownRenderer.js +39 -44
  73. package/dist/react/components/rtc-widget/components/MarkdownRenderer.js.map +1 -1
  74. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.d.ts +1 -1
  75. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.d.ts.map +1 -1
  76. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.js +2 -2
  77. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.js.map +1 -1
  78. package/dist/react/hooks/useConvaiClient.d.ts +2 -0
  79. package/dist/react/hooks/useConvaiClient.d.ts.map +1 -1
  80. package/dist/react/hooks/useConvaiClient.js +16 -0
  81. package/dist/react/hooks/useConvaiClient.js.map +1 -1
  82. package/dist/react/index.d.ts +1 -1
  83. package/dist/react/index.d.ts.map +1 -1
  84. package/dist/react/index.js +1 -1
  85. package/dist/react/index.js.map +1 -1
  86. package/dist/utils/inlineMarkdown.d.ts +38 -0
  87. package/dist/utils/inlineMarkdown.d.ts.map +1 -0
  88. package/dist/utils/inlineMarkdown.js +106 -0
  89. package/dist/utils/inlineMarkdown.js.map +1 -0
  90. package/dist/vanilla/AudioRenderer.d.ts.map +1 -1
  91. package/dist/vanilla/AudioRenderer.js +6 -0
  92. package/dist/vanilla/AudioRenderer.js.map +1 -1
  93. package/dist/vanilla/ConvaiWidget.d.ts.map +1 -1
  94. package/dist/vanilla/ConvaiWidget.js +224 -89
  95. package/dist/vanilla/ConvaiWidget.js.map +1 -1
  96. package/dist/vanilla/WidgetEventSubscriptions.d.ts +15 -0
  97. package/dist/vanilla/WidgetEventSubscriptions.d.ts.map +1 -0
  98. package/dist/vanilla/WidgetEventSubscriptions.js +37 -0
  99. package/dist/vanilla/WidgetEventSubscriptions.js.map +1 -0
  100. package/dist/vanilla/index.d.ts +2 -1
  101. package/dist/vanilla/index.d.ts.map +1 -1
  102. package/dist/vanilla/index.js +2 -1
  103. package/dist/vanilla/index.js.map +1 -1
  104. package/dist/vanilla/types.d.ts +8 -1
  105. package/dist/vanilla/types.d.ts.map +1 -1
  106. package/dist/vanilla/types.js.map +1 -1
  107. package/dist/version.d.ts +1 -1
  108. package/dist/version.js +1 -1
  109. package/dist/version.js.map +1 -1
  110. package/package.json +19 -6
@@ -1,6 +1,24 @@
1
1
  import { RoomEvent } from "livekit-client";
2
2
  import { EventEmitter } from "./EventEmitter.js";
3
+ import { extractModelOutputPayload, parseModelOutputMessage, } from "./modelOutput.js";
4
+ import { isValidLogicalTurnId, isValidOutputId } from "./logicalTurn.js";
3
5
  import { BlendshapeQueue, } from "./BlendshapeQueue.js";
6
+ function stableJsonFingerprint(value) {
7
+ if (Array.isArray(value)) {
8
+ return `[${value.map(stableJsonFingerprint).join(",")}]`;
9
+ }
10
+ if (value && typeof value === "object") {
11
+ const entries = Object.entries(value)
12
+ .sort(([left], [right]) => left.localeCompare(right))
13
+ .map(([key, item]) => `${JSON.stringify(key)}:${stableJsonFingerprint(item)}`);
14
+ return `{${entries.join(",")}}`;
15
+ }
16
+ return JSON.stringify(value);
17
+ }
18
+ const MAX_MODEL_OUTPUT_REPLAY_FINGERPRINTS = 400;
19
+ // Preserve exact replay matching instead of accepting hash collisions, while
20
+ // bounding the aggregate strings retained by a long-lived v2 session.
21
+ const MAX_MODEL_OUTPUT_REPLAY_RETAINED_CHARS = 4 * 1024 * 1024;
4
22
  /**
5
23
  * Handles incoming messages from Convai
6
24
  * Processes various message types from the AI assistant and updates chat history
@@ -18,6 +36,9 @@ export class MessageHandler extends EventEmitter {
18
36
  this.isListening = false;
19
37
  this.totalBlendshapesReceived = 0;
20
38
  this._currentEmotion = null;
39
+ this.modelOutputVersion = 1;
40
+ this.seenModelOutputPayloads = new Set();
41
+ this.seenModelOutputPayloadCharacters = 0;
21
42
  // bot-output segments already added to the chat. The server re-sends the
22
43
  // same segment (full text) as TTS progress advances (spoken_status
23
44
  // in-progress → completed); only the first sighting may touch the chat.
@@ -40,6 +61,11 @@ export class MessageHandler extends EventEmitter {
40
61
  setRtviMessageLogging(enabled) {
41
62
  this.logRtviMessages = enabled;
42
63
  }
64
+ /** Select the server-negotiated canonical output version for this session. */
65
+ setModelOutputVersion(version) {
66
+ this.modelOutputVersion = version;
67
+ this._clearModelOutputReplayLedger();
68
+ }
43
69
  /**
44
70
  * Get current chat messages
45
71
  */
@@ -130,12 +156,37 @@ export class MessageHandler extends EventEmitter {
130
156
  // Parse JSON
131
157
  const messageData = JSON.parse(messageString);
132
158
  parsed = true;
159
+ // Canonical output has two transport envelopes. Normalize it before the
160
+ // legacy message switch so both LiveKit and WebSocket execute one path.
161
+ const modelOutputPayload = extractModelOutputPayload(messageData);
162
+ if (modelOutputPayload !== undefined) {
163
+ this._handleModelOutput(modelOutputPayload);
164
+ return;
165
+ }
133
166
  // Extract and categorize messages for chat display
134
167
  const timestamp = new Date().toISOString();
135
168
  const messageId = `${messageData.type}-${Date.now()}-${Math.random()}`;
169
+ // Multi-character routing identity, carried inline on every
170
+ // character-originated event. Undefined in single-character sessions, so
171
+ // these spread to nothing and every existing message keeps its shape.
172
+ const attribution = {
173
+ ...(messageData.data?.membership_id && {
174
+ membershipId: messageData.data.membership_id,
175
+ }),
176
+ ...(messageData.data?.character_id && {
177
+ characterId: messageData.data.character_id,
178
+ }),
179
+ };
180
+ /**
181
+ * Two instances must never share a streaming bubble. Without this a
182
+ * target switch mid-turn splices one character's text onto another's,
183
+ * because the merge would only be checking `isStreaming`.
184
+ */
185
+ const sameSpeaker = (msg) => !!msg && msg.membershipId === attribution.membershipId;
136
186
  // Handle different message types
137
187
  switch (messageData.type) {
138
- // User text messages - Skip these, only allow user-transcription
188
+ // Submitted text is appended locally by sendUserTextMessage. Ignore
189
+ // server echoes so each user message appears exactly once.
139
190
  case "user_text_message":
140
191
  case "user-llm-text":
141
192
  case "text-input":
@@ -144,24 +195,42 @@ export class MessageHandler extends EventEmitter {
144
195
  case "chat-message":
145
196
  case "input-text":
146
197
  case "message":
147
- // Skip user text messages - only allow user-transcription
198
+ // Skip server echoes; local text and voice transcription are handled separately.
148
199
  break;
149
200
  // Bot Ready - Bot is ready, connection should be true
150
201
  case "bot-ready":
151
- this.emit("botReady");
202
+ // `about` carries this instance's routing identity in a roster room,
203
+ // and the same fields are also flattened onto `data`. Prefer `about`,
204
+ // fall back to the flattened copy, and pass undefined for a
205
+ // single-character session.
206
+ this.emit("botReady", messageData.data?.about ??
207
+ (messageData.data?.membership_id ? messageData.data : undefined));
152
208
  break;
153
209
  // Bot LLM Started - Begin streaming response
154
210
  case "bot-llm-started":
155
211
  this.isBotResponding = true;
156
212
  this.emit("botRespondingChange", true);
157
213
  this.seenBotOutputSegments.clear();
158
- // Add initial streaming message to chat
214
+ // Lifecycle frames do not carry output_id. In v2, keep at most one
215
+ // unowned placeholder so concurrent starts cannot create anonymous
216
+ // rows that later get adopted in reverse order. Invocation-correlated
217
+ // chunks establish additional rows in authoritative arrival order.
218
+ const hasUnownedV2Starter = this.modelOutputVersion === 2 &&
219
+ this.chatMessages.some((message) => message.type === "bot-llm-text" &&
220
+ message.isStreaming &&
221
+ message.content === "" &&
222
+ message.outputId === undefined &&
223
+ message.logicalTurnId === undefined);
224
+ if (hasUnownedV2Starter)
225
+ break;
226
+ // Add initial streaming message to chat.
159
227
  const streamingMessage = {
160
228
  id: messageId,
161
229
  type: "bot-llm-text",
162
230
  content: "",
163
231
  timestamp: timestamp,
164
232
  isStreaming: true,
233
+ ...attribution,
165
234
  };
166
235
  this.activeBotLlmMessageId = messageId;
167
236
  this.chatMessages.push(streamingMessage);
@@ -205,7 +274,9 @@ export class MessageHandler extends EventEmitter {
205
274
  lastMsg.content.includes(newText)) {
206
275
  break;
207
276
  }
208
- if (lastMsg?.type === "bot-output" && lastMsg.isStreaming) {
277
+ if (lastMsg?.type === "bot-output" &&
278
+ lastMsg.isStreaming &&
279
+ sameSpeaker(lastMsg)) {
209
280
  this.chatMessages[this.chatMessages.length - 1] = {
210
281
  ...lastMsg,
211
282
  content: lastMsg.content + newText,
@@ -218,6 +289,7 @@ export class MessageHandler extends EventEmitter {
218
289
  content: newText,
219
290
  timestamp: new Date().toISOString(),
220
291
  isStreaming: true,
292
+ ...attribution,
221
293
  });
222
294
  }
223
295
  this.emit("messagesChange", this.chatMessages);
@@ -227,58 +299,223 @@ export class MessageHandler extends EventEmitter {
227
299
  case "bot-llm-text":
228
300
  if (messageData.data?.text) {
229
301
  const newChunk = messageData.data.text;
230
- // Update the streaming message in chat
231
- const activeIndex = this.activeBotLlmMessageId
232
- ? this.chatMessages.findIndex((message) => message.id === this.activeBotLlmMessageId &&
233
- message.type === "bot-llm-text" &&
234
- message.isStreaming)
235
- : -1;
236
- if (activeIndex >= 0) {
237
- const activeMessage = this.chatMessages[activeIndex];
238
- this.chatMessages[activeIndex] = {
239
- ...activeMessage,
240
- content: activeMessage.content + newChunk,
302
+ // Core adds this correlation metadata only for negotiated v2.
303
+ // Legacy frames retain their exact historical shape and behavior.
304
+ const outputId = this.modelOutputVersion === 2 &&
305
+ isValidOutputId(messageData.data.output_id)
306
+ ? messageData.data.output_id
307
+ : undefined;
308
+ const logicalTurnId = this.modelOutputVersion === 2 &&
309
+ isValidLogicalTurnId(messageData.data.logical_turn_id)
310
+ ? messageData.data.logical_turn_id
311
+ : undefined;
312
+ // A logical turn can contain multiple provider invocations (for
313
+ // example, the response before a tool call and the continuation
314
+ // after its result). Prefer the invocation id, then fall back to
315
+ // the older v2 logical-turn id when Core does not send one.
316
+ let targetMessageIndex = -1;
317
+ if (outputId !== undefined) {
318
+ for (let index = this.chatMessages.length - 1; index >= 0; index--) {
319
+ const message = this.chatMessages[index];
320
+ if (message.type === "bot-llm-text" &&
321
+ message.isStreaming &&
322
+ message.outputId === outputId &&
323
+ sameSpeaker(message)) {
324
+ targetMessageIndex = index;
325
+ break;
326
+ }
327
+ }
328
+ // bot-llm-started has no correlation metadata. Let one empty,
329
+ // unowned row adopt the first authoritative invocation id.
330
+ if (targetMessageIndex === -1) {
331
+ for (let index = this.chatMessages.length - 1; index >= 0; index--) {
332
+ const message = this.chatMessages[index];
333
+ if (message.type === "bot-llm-text" &&
334
+ message.isStreaming &&
335
+ message.content === "" &&
336
+ message.outputId === undefined &&
337
+ message.logicalTurnId === undefined &&
338
+ sameSpeaker(message)) {
339
+ targetMessageIndex = index;
340
+ break;
341
+ }
342
+ }
343
+ }
344
+ }
345
+ else if (logicalTurnId !== undefined) {
346
+ for (let index = this.chatMessages.length - 1; index >= 0; index--) {
347
+ const message = this.chatMessages[index];
348
+ if (message.type === "bot-llm-text" &&
349
+ message.isStreaming &&
350
+ message.outputId === undefined &&
351
+ message.logicalTurnId === logicalTurnId &&
352
+ sameSpeaker(message)) {
353
+ targetMessageIndex = index;
354
+ break;
355
+ }
356
+ }
357
+ // Older v2 Core still needs the starter-row behavior that was
358
+ // introduced with logical-turn correlation.
359
+ if (targetMessageIndex === -1) {
360
+ const lastIndex = this.chatMessages.length - 1;
361
+ const lastMessage = this.chatMessages[lastIndex];
362
+ if (lastMessage?.type === "bot-llm-text" &&
363
+ lastMessage.isStreaming &&
364
+ lastMessage.content === "" &&
365
+ lastMessage.outputId === undefined &&
366
+ lastMessage.logicalTurnId === undefined &&
367
+ sameSpeaker(lastMessage)) {
368
+ targetMessageIndex = lastIndex;
369
+ }
370
+ }
371
+ }
372
+ else {
373
+ // Uncorrelated (v1) chunk: it belongs to the bubble bot-llm-started
374
+ // opened for this speaker. Only when no such bubble exists does the
375
+ // historical "last streaming row" rule apply -- and still only for
376
+ // the same speaker, so a target switch mid-turn cannot splice one
377
+ // member's text onto another's row.
378
+ // Ownership applies among uncorrelated rows only: an uncorrelated
379
+ // chunk must never join a row that carries an output or turn id,
380
+ // or a v2 correlated stream would absorb legacy text.
381
+ if (this.activeBotLlmMessageId) {
382
+ targetMessageIndex = this.chatMessages.findIndex((message) => message.id === this.activeBotLlmMessageId &&
383
+ message.type === "bot-llm-text" &&
384
+ message.isStreaming &&
385
+ !message.outputId &&
386
+ !message.logicalTurnId &&
387
+ sameSpeaker(message));
388
+ }
389
+ if (targetMessageIndex === -1) {
390
+ const lastIndex = this.chatMessages.length - 1;
391
+ const lastMessage = this.chatMessages[lastIndex];
392
+ if (lastMessage &&
393
+ lastMessage.type === "bot-llm-text" &&
394
+ lastMessage.isStreaming &&
395
+ !lastMessage.outputId &&
396
+ !lastMessage.logicalTurnId &&
397
+ sameSpeaker(lastMessage)) {
398
+ targetMessageIndex = lastIndex;
399
+ }
400
+ }
401
+ }
402
+ let updatedMessage;
403
+ if (targetMessageIndex !== -1) {
404
+ const targetMessage = this.chatMessages[targetMessageIndex];
405
+ updatedMessage = {
406
+ ...targetMessage,
407
+ content: targetMessage.content + newChunk,
408
+ ...(outputId !== undefined &&
409
+ targetMessage.outputId === undefined
410
+ ? { outputId }
411
+ : {}),
412
+ ...(logicalTurnId !== undefined &&
413
+ targetMessage.logicalTurnId === undefined
414
+ ? { logicalTurnId }
415
+ : {}),
241
416
  };
417
+ this.chatMessages[targetMessageIndex] = updatedMessage;
242
418
  }
243
419
  else {
244
420
  // If no streaming message exists yet, create one
245
- const streamingMsg = {
421
+ updatedMessage = {
246
422
  id: `${messageData.type}-${Date.now()}-${Math.random()}`,
247
423
  type: "bot-llm-text",
248
424
  content: newChunk,
249
425
  timestamp: new Date().toISOString(),
250
426
  isStreaming: true,
427
+ ...(outputId ? { outputId } : {}),
428
+ ...(logicalTurnId ? { logicalTurnId } : {}),
429
+ ...attribution,
251
430
  };
252
- this.activeBotLlmMessageId = streamingMsg.id;
253
- this.chatMessages.push(streamingMsg);
431
+ this.activeBotLlmMessageId = updatedMessage.id;
432
+ this.chatMessages.push(updatedMessage);
254
433
  }
255
- this.emit("messagesChange", this.chatMessages);
434
+ this.emit("messagesChange", this.chatMessages, updatedMessage);
256
435
  }
257
436
  break;
437
+ // Parallel diagnostics-only provider output. This must never affect
438
+ // normal chat, speech, TTS, or response lifecycle state.
439
+ case "bot-llm-text-raw":
440
+ this._handleBotLlmTextRaw(messageData.data);
441
+ break;
258
442
  // Bot LLM Stopped - Complete streaming response
259
- case "bot-llm-stopped":
260
- const activeIndex = this.activeBotLlmMessageId
443
+ case "bot-llm-stopped": {
444
+ // A v2 response can have interleaved correlated rows. The stop event
445
+ // is response-scoped, so none may remain open for later chunks.
446
+ const finalizedMessages = [];
447
+ if (this.modelOutputVersion === 2) {
448
+ for (let index = 0; index < this.chatMessages.length; index++) {
449
+ const message = this.chatMessages[index];
450
+ if ((message.type === "bot-llm-text" ||
451
+ message.type === "bot-output") &&
452
+ message.isStreaming) {
453
+ const finalizedMessage = {
454
+ ...message,
455
+ isStreaming: false,
456
+ };
457
+ this.chatMessages[index] = finalizedMessage;
458
+ finalizedMessages.push(finalizedMessage);
459
+ }
460
+ }
461
+ }
462
+ else {
463
+ // v1: a stop frame finalizes the bubble this turn owns; only when no
464
+ // bubble was opened does the historical last-row rule apply.
465
+ let stopIndex = this.activeBotLlmMessageId
466
+ ? this.chatMessages.findIndex((message) => message.id === this.activeBotLlmMessageId)
467
+ : -1;
468
+ if (stopIndex === -1)
469
+ stopIndex = this.chatMessages.length - 1;
470
+ const lastBotMsg = this.chatMessages[stopIndex];
471
+ if (lastBotMsg && lastBotMsg.isStreaming) {
472
+ const finalizedMessage = { ...lastBotMsg, isStreaming: false };
473
+ this.chatMessages[stopIndex] = finalizedMessage;
474
+ finalizedMessages.push(finalizedMessage);
475
+ }
476
+ }
477
+ // Clear streaming state
478
+ this.isBotResponding = false;
479
+ this.emit("botRespondingChange", false);
480
+ // Keep the full-list event at one emission per stop while carrying
481
+ // every v2 terminal delta for singular-message consumers.
482
+ this.emit("messagesChange", this.chatMessages, undefined, finalizedMessages);
483
+ break;
484
+ }
485
+ // Text-only Interactions API terminal marker. This is deliberately a
486
+ // separate event: a completed REST turn must not be represented as a
487
+ // synthetic bot-llm-stopped message.
488
+ case "bot-turn-completed":
489
+ const completedIndex = this.activeBotLlmMessageId
261
490
  ? this.chatMessages.findIndex((message) => message.id === this.activeBotLlmMessageId)
262
491
  : -1;
263
- if (activeIndex >= 0 && this.chatMessages[activeIndex].isStreaming) {
264
- const activeMessage = this.chatMessages[activeIndex];
265
- this.chatMessages[activeIndex] = {
266
- ...activeMessage,
492
+ if (completedIndex >= 0 && this.chatMessages[completedIndex].isStreaming) {
493
+ this.chatMessages[completedIndex] = {
494
+ ...this.chatMessages[completedIndex],
267
495
  isStreaming: false,
268
496
  };
497
+ this.emit("messagesChange", this.chatMessages);
269
498
  }
270
- // Clear streaming state
271
499
  this.isBotResponding = false;
272
500
  this.emit("botRespondingChange", false);
273
- this.emit("messagesChange", this.chatMessages);
501
+ this.emit("botTurnCompleted", messageData.data ?? {});
274
502
  break;
275
503
  // User Transcription Messages
276
504
  case "user-transcription":
277
505
  if (messageData.data?.text) {
278
506
  const messageContent = messageData.data.text;
279
507
  if (messageData.data?.final) {
508
+ const logicalTurnId = this.transcriptionLogicalTurnId(messageData.data.logical_turn_id);
280
509
  // Final transcription - accumulate in single bubble until bot responds
281
510
  const lastUserMsg = this.chatMessages[this.chatMessages.length - 1];
511
+ // A final transcription identical to the line appended locally on send is
512
+ // the server echoing that send back: drop it, or the user sees it twice.
513
+ if (lastUserMsg?.type === "user-llm-text" &&
514
+ lastUserMsg.content.trim() === String(messageContent).trim()) {
515
+ this.userTranscription = "";
516
+ this.emit("userTranscriptionChange", "");
517
+ break;
518
+ }
282
519
  // Check if last message is an accumulating user message (isStreaming=true)
283
520
  if (lastUserMsg &&
284
521
  lastUserMsg.type === "user-transcription" &&
@@ -287,6 +524,7 @@ export class MessageHandler extends EventEmitter {
287
524
  const accumulatedContent = lastUserMsg.content.trim() + " " + messageContent.trim();
288
525
  this.chatMessages[this.chatMessages.length - 1] = {
289
526
  ...lastUserMsg,
527
+ ...(logicalTurnId === undefined ? {} : { logicalTurnId }),
290
528
  content: accumulatedContent,
291
529
  timestamp: timestamp,
292
530
  };
@@ -299,6 +537,7 @@ export class MessageHandler extends EventEmitter {
299
537
  content: messageContent,
300
538
  timestamp: timestamp,
301
539
  isStreaming: true,
540
+ ...(logicalTurnId === undefined ? {} : { logicalTurnId }),
302
541
  };
303
542
  this.chatMessages.push(userMessage);
304
543
  }
@@ -321,7 +560,8 @@ export class MessageHandler extends EventEmitter {
321
560
  emotion: messageData.data.emotion,
322
561
  scale: messageData.data.scale,
323
562
  };
324
- console.log("[SDK bot-emotion]", messageData.data);
563
+ if (this.logRtviMessages)
564
+ console.log("[SDK bot-emotion]", messageData.data);
325
565
  this.emit("emotionChange", this._currentEmotion);
326
566
  const emotionMessage = {
327
567
  id: messageId,
@@ -395,6 +635,14 @@ export class MessageHandler extends EventEmitter {
395
635
  this.emit("metrics", messageData.data);
396
636
  }
397
637
  break;
638
+ // Per-turn usage/cost breakdown. Informational only: the server streams
639
+ // it once the client opts in with usage-toggle, and it never affects
640
+ // server-side usage tracking or billing.
641
+ case "usage-update":
642
+ if (messageData.data) {
643
+ this.emit("usageUpdate", messageData.data);
644
+ }
645
+ break;
398
646
  // Behavior Tree Response Messages
399
647
  case "behavior-tree-response":
400
648
  if (messageData.data?.narrative_section_id) {
@@ -413,15 +661,30 @@ export class MessageHandler extends EventEmitter {
413
661
  if (messageData.data) {
414
662
  const serverData = messageData.data;
415
663
  switch (serverData.type) {
664
+ case "bot-llm-text-raw":
665
+ this._handleBotLlmTextRaw(serverData.data ?? serverData);
666
+ break;
416
667
  case "final-user-transcription":
417
668
  if (serverData.text) {
418
669
  const finalText = serverData.text;
670
+ const logicalTurnId = this.transcriptionLogicalTurnId(serverData.logical_turn_id);
419
671
  const lastUserMsg = this.chatMessages[this.chatMessages.length - 1];
672
+ // A final transcription identical to the line appended locally on send is
673
+ // the server echoing that send back: drop it, or the user sees it twice.
674
+ if (lastUserMsg?.type === "user-llm-text" &&
675
+ lastUserMsg.content.trim() === String(finalText).trim()) {
676
+ this.userTranscription = "";
677
+ this.emit("userTranscriptionChange", "");
678
+ break;
679
+ }
420
680
  if (lastUserMsg &&
421
681
  lastUserMsg.type === "user-transcription" &&
422
682
  lastUserMsg.isStreaming === true) {
423
683
  this.chatMessages[this.chatMessages.length - 1] = {
424
684
  ...lastUserMsg,
685
+ ...(logicalTurnId === undefined
686
+ ? {}
687
+ : { logicalTurnId }),
425
688
  content: finalText,
426
689
  timestamp,
427
690
  isStreaming: false,
@@ -434,6 +697,9 @@ export class MessageHandler extends EventEmitter {
434
697
  content: finalText,
435
698
  timestamp,
436
699
  isStreaming: false,
700
+ ...(logicalTurnId === undefined
701
+ ? {}
702
+ : { logicalTurnId }),
437
703
  });
438
704
  }
439
705
  this.emit("messagesChange", this.chatMessages);
@@ -448,7 +714,8 @@ export class MessageHandler extends EventEmitter {
448
714
  emotion: serverData.emotion,
449
715
  scale: serverData.scale,
450
716
  };
451
- console.log("[SDK bot-emotion (ws)]", serverData);
717
+ if (this.logRtviMessages)
718
+ console.log("[SDK bot-emotion (ws)]", serverData);
452
719
  this.emit("emotionChange", this._currentEmotion);
453
720
  }
454
721
  break;
@@ -535,6 +802,35 @@ export class MessageHandler extends EventEmitter {
535
802
  this.emit("serverResponse", serverResponse);
536
803
  break;
537
804
  }
805
+ /**
806
+ * Lifecycle of one roster member. Emitted when a member is added
807
+ * at runtime, becomes ready, or fails after /connect already
808
+ * returned 200. `bot-ready` stays the readiness authority --
809
+ * a `character-status` of "ready" must not enable interaction on
810
+ * its own -- but this is the only way a background dispatch
811
+ * failure is ever reported.
812
+ */
813
+ case "character-status": {
814
+ const status = {
815
+ status: serverData.status ?? "starting",
816
+ membershipId: serverData.membership_id ?? "",
817
+ characterId: serverData.character_id ?? "",
818
+ characterSessionId: serverData.character_session_id ?? null,
819
+ participantIdentity: serverData.participant_identity ??
820
+ (serverData.membership_id
821
+ ? `character:${serverData.membership_id}`
822
+ : ""),
823
+ roomSessionId: serverData.room_session_id ?? null,
824
+ rosterEpoch: typeof serverData.roster_epoch === "number"
825
+ ? serverData.roster_epoch
826
+ : null,
827
+ failureCode: serverData.failure_code ?? null,
828
+ };
829
+ // Unattributable status is dropped rather than guessed at.
830
+ if (status.membershipId)
831
+ this.emit("characterStatus", status);
832
+ break;
833
+ }
538
834
  // interaction-created: session IDs for analytics/tracking
539
835
  case "interaction-created": {
540
836
  const interactionCreated = {
@@ -574,6 +870,10 @@ export class MessageHandler extends EventEmitter {
574
870
  }
575
871
  }
576
872
  _handleActionResponse(actions, messageId, timestamp) {
873
+ // model-output v2 is canonical. action-response remains a compatibility
874
+ // projection and executing both would duplicate the same operation.
875
+ if (this.modelOutputVersion === 2)
876
+ return;
577
877
  this.emit("actionResponse", { actions });
578
878
  const label = actions.length === 0
579
879
  ? "No action"
@@ -587,6 +887,66 @@ export class MessageHandler extends EventEmitter {
587
887
  this.chatMessages.push(actionMessage);
588
888
  this.emit("messagesChange", this.chatMessages);
589
889
  }
890
+ _handleModelOutput(value) {
891
+ if (this.modelOutputVersion !== 2)
892
+ return;
893
+ const output = parseModelOutputMessage(value);
894
+ if (!output) {
895
+ this.emit("modelOutputProtocolError", {
896
+ code: "invalid_model_output",
897
+ message: "Core sent a malformed or unsupported model-output envelope",
898
+ });
899
+ return;
900
+ }
901
+ // output_id identifies a provider invocation, not a serialized envelope.
902
+ // One invocation can produce distinct text and tool-call projections with
903
+ // the same id, and consumers must receive both in transport arrival order.
904
+ // Suppress only an exact transport replay of an already delivered payload.
905
+ const fingerprint = stableJsonFingerprint(output);
906
+ if (this.seenModelOutputPayloads.has(fingerprint))
907
+ return;
908
+ if (fingerprint.length > MAX_MODEL_OUTPUT_REPLAY_RETAINED_CHARS) {
909
+ this.emit("modelOutputProtocolError", {
910
+ code: "invalid_model_output",
911
+ message: "Core sent an oversized model-output envelope",
912
+ });
913
+ return;
914
+ }
915
+ this.seenModelOutputPayloads.add(fingerprint);
916
+ this.seenModelOutputPayloadCharacters += fingerprint.length;
917
+ while (this.seenModelOutputPayloads.size > MAX_MODEL_OUTPUT_REPLAY_FINGERPRINTS ||
918
+ this.seenModelOutputPayloadCharacters >
919
+ MAX_MODEL_OUTPUT_REPLAY_RETAINED_CHARS) {
920
+ const oldestFingerprint = this.seenModelOutputPayloads.values().next()
921
+ .value;
922
+ if (!oldestFingerprint)
923
+ break;
924
+ this.seenModelOutputPayloads.delete(oldestFingerprint);
925
+ this.seenModelOutputPayloadCharacters -= oldestFingerprint.length;
926
+ }
927
+ this.emit("modelOutput", output);
928
+ }
929
+ _clearModelOutputReplayLedger() {
930
+ this.seenModelOutputPayloads.clear();
931
+ this.seenModelOutputPayloadCharacters = 0;
932
+ }
933
+ _handleBotLlmTextRaw(value) {
934
+ if (this.modelOutputVersion !== 2 || !value || typeof value !== "object") {
935
+ return;
936
+ }
937
+ const data = value;
938
+ if (typeof data.text !== "string" ||
939
+ !isValidLogicalTurnId(data.logical_turn_id)) {
940
+ return;
941
+ }
942
+ this.emit("botLlmTextRaw", {
943
+ text: data.text,
944
+ logical_turn_id: data.logical_turn_id,
945
+ ...(isValidOutputId(data.output_id)
946
+ ? { output_id: data.output_id }
947
+ : {}),
948
+ });
949
+ }
590
950
  lifecycleOwner(data) {
591
951
  if (!data) {
592
952
  return {};
@@ -604,12 +964,51 @@ export class MessageHandler extends EventEmitter {
604
964
  ? value
605
965
  : undefined;
606
966
  }
967
+ transcriptionLogicalTurnId(value) {
968
+ return this.modelOutputVersion === 2 && isValidLogicalTurnId(value)
969
+ ? value
970
+ : undefined;
971
+ }
607
972
  /**
608
973
  * Public entry point for WebSocket transport to inject messages into the same pipeline.
609
974
  */
610
975
  handleDataReceivedPublic(payload) {
611
976
  this.handleDataReceived(payload, null, "websocket");
612
977
  }
978
+ /** Append a submitted text message without waiting for a server echo. */
979
+ addUserTextMessage(text) {
980
+ const content = text.trim();
981
+ if (!content)
982
+ return;
983
+ this.chatMessages.push({
984
+ id: `user-llm-text-${Date.now()}-${Math.random()}`,
985
+ type: "user-llm-text",
986
+ content,
987
+ timestamp: new Date().toISOString(),
988
+ });
989
+ this.emit("messagesChange", this.chatMessages);
990
+ }
991
+ /**
992
+ * Insert a message into the transcript locally. Nothing is transmitted and
993
+ * no LLM turn is billed -- this is for client-side lines (a greeting, a
994
+ * system notice, a replayed history entry) that must appear in the chat.
995
+ *
996
+ * Subscribers see it through the same `messagesChange` as a live message,
997
+ * so React and the widgets pick it up with no special casing.
998
+ */
999
+ appendLocalMessage(message) {
1000
+ const chatMessage = {
1001
+ id: `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
1002
+ type: message.sender === "user" ? "user-llm-text" : "bot-llm-text",
1003
+ content: message.content,
1004
+ timestamp: new Date().toISOString(),
1005
+ ...(message.membershipId && { membershipId: message.membershipId }),
1006
+ ...(message.characterId && { characterId: message.characterId }),
1007
+ };
1008
+ this.chatMessages.push(chatMessage);
1009
+ this.emit("messagesChange", this.chatMessages);
1010
+ return chatMessage;
1011
+ }
613
1012
  /**
614
1013
  * Reset message state
615
1014
  */
@@ -622,6 +1021,8 @@ export class MessageHandler extends EventEmitter {
622
1021
  this.isListening = false;
623
1022
  this.totalBlendshapesReceived = 0;
624
1023
  this._currentEmotion = null;
1024
+ this.seenBotOutputSegments.clear();
1025
+ this._clearModelOutputReplayLedger();
625
1026
  this.blendshapeQueue.reset();
626
1027
  this.emit("messagesChange", this.chatMessages);
627
1028
  this.emit("userTranscriptionChange", "");