@convai/web-sdk 1.8.0-beta.6 → 1.8.0-beta.8

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 (99) hide show
  1. package/README.md +303 -1
  2. package/dist/core/ConvaiClient.d.ts +59 -2
  3. package/dist/core/ConvaiClient.d.ts.map +1 -1
  4. package/dist/core/ConvaiClient.js +457 -6
  5. package/dist/core/ConvaiClient.js.map +1 -1
  6. package/dist/core/MessageHandler.d.ts +11 -0
  7. package/dist/core/MessageHandler.d.ts.map +1 -1
  8. package/dist/core/MessageHandler.js +325 -30
  9. package/dist/core/MessageHandler.js.map +1 -1
  10. package/dist/core/SSESession.js +2 -2
  11. package/dist/core/SSESession.js.map +1 -1
  12. package/dist/core/connectRequest.d.ts +38 -3
  13. package/dist/core/connectRequest.d.ts.map +1 -1
  14. package/dist/core/connectRequest.js +57 -11
  15. package/dist/core/connectRequest.js.map +1 -1
  16. package/dist/core/index.d.ts +5 -1
  17. package/dist/core/index.d.ts.map +1 -1
  18. package/dist/core/index.js +8 -1
  19. package/dist/core/index.js.map +1 -1
  20. package/dist/core/logicalTurn.d.ts +4 -1
  21. package/dist/core/logicalTurn.d.ts.map +1 -1
  22. package/dist/core/logicalTurn.js +35 -4
  23. package/dist/core/logicalTurn.js.map +1 -1
  24. package/dist/core/modelOutput.d.ts +40 -0
  25. package/dist/core/modelOutput.d.ts.map +1 -0
  26. package/dist/core/modelOutput.js +187 -0
  27. package/dist/core/modelOutput.js.map +1 -0
  28. package/dist/core/publishedChat.d.ts +9 -0
  29. package/dist/core/publishedChat.d.ts.map +1 -0
  30. package/dist/core/publishedChat.js +13 -0
  31. package/dist/core/publishedChat.js.map +1 -0
  32. package/dist/core/types.d.ts +225 -25
  33. package/dist/core/types.d.ts.map +1 -1
  34. package/dist/core/types.js.map +1 -1
  35. package/dist/embed/ConvaiChatElement.d.ts +103 -0
  36. package/dist/embed/ConvaiChatElement.d.ts.map +1 -0
  37. package/dist/embed/ConvaiChatElement.js +679 -0
  38. package/dist/embed/ConvaiChatElement.js.map +1 -0
  39. package/dist/embed/browser.d.ts +3 -0
  40. package/dist/embed/browser.d.ts.map +1 -0
  41. package/dist/embed/browser.js +5 -0
  42. package/dist/embed/browser.js.map +1 -0
  43. package/dist/embed/chat-embed-v1.js +200 -0
  44. package/dist/embed/chat-embed-v1.js.map +7 -0
  45. package/dist/embed/index.d.ts +9 -0
  46. package/dist/embed/index.d.ts.map +1 -0
  47. package/dist/embed/index.js +9 -0
  48. package/dist/embed/index.js.map +1 -0
  49. package/dist/embed/publicationApi.d.ts +23 -0
  50. package/dist/embed/publicationApi.d.ts.map +1 -0
  51. package/dist/embed/publicationApi.js +141 -0
  52. package/dist/embed/publicationApi.js.map +1 -0
  53. package/dist/react/components/ConvaiWidget.d.ts +11 -1
  54. package/dist/react/components/ConvaiWidget.d.ts.map +1 -1
  55. package/dist/react/components/ConvaiWidget.js +67 -11
  56. package/dist/react/components/ConvaiWidget.js.map +1 -1
  57. package/dist/react/components/rtc-widget/components/MarkdownRenderer.d.ts +7 -0
  58. package/dist/react/components/rtc-widget/components/MarkdownRenderer.d.ts.map +1 -1
  59. package/dist/react/components/rtc-widget/components/MarkdownRenderer.js +111 -17
  60. package/dist/react/components/rtc-widget/components/MarkdownRenderer.js.map +1 -1
  61. package/dist/react/components/rtc-widget/components/UserMessage.js +1 -1
  62. package/dist/react/components/rtc-widget/components/UserMessage.js.map +1 -1
  63. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.d.ts +1 -1
  64. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.d.ts.map +1 -1
  65. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.js +2 -2
  66. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.js.map +1 -1
  67. package/dist/react/hooks/useConvaiClient.d.ts +2 -0
  68. package/dist/react/hooks/useConvaiClient.d.ts.map +1 -1
  69. package/dist/react/hooks/useConvaiClient.js +8 -0
  70. package/dist/react/hooks/useConvaiClient.js.map +1 -1
  71. package/dist/react/index.d.ts +4 -1
  72. package/dist/react/index.d.ts.map +1 -1
  73. package/dist/react/index.js +8 -1
  74. package/dist/react/index.js.map +1 -1
  75. package/dist/utils/inlineMarkdown.d.ts +22 -0
  76. package/dist/utils/inlineMarkdown.d.ts.map +1 -1
  77. package/dist/utils/inlineMarkdown.js +84 -7
  78. package/dist/utils/inlineMarkdown.js.map +1 -1
  79. package/dist/vanilla/ConvaiWidget.d.ts.map +1 -1
  80. package/dist/vanilla/ConvaiWidget.js +290 -43
  81. package/dist/vanilla/ConvaiWidget.js.map +1 -1
  82. package/dist/vanilla/WidgetEventSubscriptions.d.ts +15 -0
  83. package/dist/vanilla/WidgetEventSubscriptions.d.ts.map +1 -0
  84. package/dist/vanilla/WidgetEventSubscriptions.js +37 -0
  85. package/dist/vanilla/WidgetEventSubscriptions.js.map +1 -0
  86. package/dist/vanilla/index.d.ts +3 -1
  87. package/dist/vanilla/index.d.ts.map +1 -1
  88. package/dist/vanilla/index.js +3 -1
  89. package/dist/vanilla/index.js.map +1 -1
  90. package/dist/vanilla/styles.d.ts.map +1 -1
  91. package/dist/vanilla/styles.js +12 -0
  92. package/dist/vanilla/styles.js.map +1 -1
  93. package/dist/vanilla/types.d.ts +8 -1
  94. package/dist/vanilla/types.d.ts.map +1 -1
  95. package/dist/vanilla/types.js.map +1 -1
  96. package/dist/version.d.ts +1 -1
  97. package/dist/version.js +1 -1
  98. package/dist/version.js.map +1 -1
  99. package/package.json +48 -13
@@ -18,6 +18,9 @@ export declare class MessageHandler extends EventEmitter {
18
18
  private totalBlendshapesReceived;
19
19
  private blendshapeQueue;
20
20
  private _currentEmotion;
21
+ private modelOutputVersion;
22
+ private seenModelOutputPayloads;
23
+ private seenModelOutputPayloadCharacters;
21
24
  private seenBotOutputSegments;
22
25
  private readonly _boundHandleDataReceived;
23
26
  constructor(room?: Room);
@@ -29,6 +32,8 @@ export declare class MessageHandler extends EventEmitter {
29
32
  * Enable or disable console logging for decoded incoming RTVI messages.
30
33
  */
31
34
  setRtviMessageLogging(enabled: boolean): void;
35
+ /** Select the server-negotiated canonical output version for this session. */
36
+ setModelOutputVersion(version: 1 | 2): void;
32
37
  /**
33
38
  * Get current chat messages
34
39
  */
@@ -80,12 +85,18 @@ export declare class MessageHandler extends EventEmitter {
80
85
  */
81
86
  private handleDataReceived;
82
87
  private _handleActionResponse;
88
+ private _handleModelOutput;
89
+ private _clearModelOutputReplayLedger;
90
+ private _handleBotLlmTextRaw;
83
91
  private lifecycleOwner;
84
92
  private numberOrUndefined;
93
+ private transcriptionLogicalTurnId;
85
94
  /**
86
95
  * Public entry point for WebSocket transport to inject messages into the same pipeline.
87
96
  */
88
97
  handleDataReceivedPublic(payload: Uint8Array): void;
98
+ /** Append a submitted text message without waiting for a server echo. */
99
+ addUserTextMessage(text: string): void;
89
100
  /**
90
101
  * Insert a message into the transcript locally. Nothing is transmitted and
91
102
  * no LLM turn is billed -- this is for client-side lines (a greeting, a
@@ -1 +1 @@
1
- {"version":3,"file":"MessageHandler.d.ts","sourceRoot":"","sources":["../../src/core/MessageHandler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAa,MAAM,gBAAgB,CAAC;AACjD,OAAO,EACL,WAAW,EAKZ,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EACL,eAAe,EAEhB,MAAM,mBAAmB,CAAC;AAS3B;;;GAGG;AACH,qBAAa,cAAe,SAAQ,YAAY;IAC9C,OAAO,CAAC,IAAI,CAAqB;IACjC,OAAO,CAAC,YAAY,CAAqB;IACzC,OAAO,CAAC,qBAAqB,CAAuB;IACpD,OAAO,CAAC,iBAAiB,CAAc;IACvC,OAAO,CAAC,eAAe,CAAiB;IACxC,OAAO,CAAC,eAAe,CAAkB;IACzC,OAAO,CAAC,UAAU,CAAkB;IACpC,OAAO,CAAC,WAAW,CAAkB;IACrC,OAAO,CAAC,wBAAwB,CAAa;IAC7C,OAAO,CAAC,eAAe,CAAkB;IACzC,OAAO,CAAC,eAAe,CAAoD;IAI3E,OAAO,CAAC,qBAAqB,CAA8B;IAK3D,OAAO,CAAC,QAAQ,CAAC,wBAAwB,CAG/B;gBAEE,IAAI,CAAC,EAAE,IAAI;IASvB;;OAEG;IACH,kBAAkB,IAAI,eAAe;IAIrC;;OAEG;IACH,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI;IAI7C;;OAEG;IACH,eAAe,IAAI,WAAW,EAAE;IAIhC;;OAEG;IACH,oBAAoB,IAAI,MAAM;IAI9B;;OAEG;IACH,kBAAkB,IAAI,OAAO;IAI7B;;OAEG;IACH,aAAa,IAAI,OAAO;IAIxB;;OAEG;IACH,UAAU,IAAI;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI;IAIxD;;;OAGG;IACH,cAAc,IAAI,OAAO;IAIzB;;;;OAIG;IACH,wBAAwB,IAAI,IAAI;IAShC;;OAEG;IACH,OAAO,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI;IAUzB;;OAEG;IACH,OAAO,CAAC,cAAc;IAMtB;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAMxB;;OAEG;IACH,OAAO,CAAC,kBAAkB;IA4mB1B,OAAO,CAAC,qBAAqB;IAmB7B,OAAO,CAAC,cAAc;IAkBtB,OAAO,CAAC,iBAAiB;IAMzB;;OAEG;IACH,wBAAwB,CAAC,OAAO,EAAE,UAAU,GAAG,IAAI;IAInD;;;;;;;OAOG;IACH,kBAAkB,CAAC,OAAO,EAAE;QAC1B,MAAM,EAAE,MAAM,GAAG,KAAK,CAAC;QACvB,OAAO,EAAE,MAAM,CAAC;QAChB,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,GAAG,WAAW;IAcf;;OAEG;IACH,KAAK,IAAI,IAAI;CAiBd"}
1
+ {"version":3,"file":"MessageHandler.d.ts","sourceRoot":"","sources":["../../src/core/MessageHandler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAa,MAAM,gBAAgB,CAAC;AACjD,OAAO,EACL,WAAW,EAQZ,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAM9C,OAAO,EACL,eAAe,EAEhB,MAAM,mBAAmB,CAAC;AA8B3B;;;GAGG;AACH,qBAAa,cAAe,SAAQ,YAAY;IAC9C,OAAO,CAAC,IAAI,CAAqB;IACjC,OAAO,CAAC,YAAY,CAAqB;IACzC,OAAO,CAAC,qBAAqB,CAAuB;IACpD,OAAO,CAAC,iBAAiB,CAAc;IACvC,OAAO,CAAC,eAAe,CAAiB;IACxC,OAAO,CAAC,eAAe,CAAkB;IACzC,OAAO,CAAC,UAAU,CAAkB;IACpC,OAAO,CAAC,WAAW,CAAkB;IACrC,OAAO,CAAC,wBAAwB,CAAa;IAC7C,OAAO,CAAC,eAAe,CAAkB;IACzC,OAAO,CAAC,eAAe,CAAoD;IAC3E,OAAO,CAAC,kBAAkB,CAAY;IACtC,OAAO,CAAC,uBAAuB,CAAqB;IACpD,OAAO,CAAC,gCAAgC,CAAK;IAI7C,OAAO,CAAC,qBAAqB,CAA8B;IAK3D,OAAO,CAAC,QAAQ,CAAC,wBAAwB,CAG/B;gBAEE,IAAI,CAAC,EAAE,IAAI;IASvB;;OAEG;IACH,kBAAkB,IAAI,eAAe;IAIrC;;OAEG;IACH,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI;IAI7C,8EAA8E;IAC9E,qBAAqB,CAAC,OAAO,EAAE,CAAC,GAAG,CAAC,GAAG,IAAI;IAK3C;;OAEG;IACH,eAAe,IAAI,WAAW,EAAE;IAIhC;;OAEG;IACH,oBAAoB,IAAI,MAAM;IAI9B;;OAEG;IACH,kBAAkB,IAAI,OAAO;IAI7B;;OAEG;IACH,aAAa,IAAI,OAAO;IAIxB;;OAEG;IACH,UAAU,IAAI;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI;IAIxD;;;OAGG;IACH,cAAc,IAAI,OAAO;IAIzB;;;;OAIG;IACH,wBAAwB,IAAI,IAAI;IAShC;;OAEG;IACH,OAAO,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI;IAUzB;;OAEG;IACH,OAAO,CAAC,cAAc;IAMtB;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAMxB;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAk1B1B,OAAO,CAAC,qBAAqB;IAsB7B,OAAO,CAAC,kBAAkB;IAuC1B,OAAO,CAAC,6BAA6B;IAKrC,OAAO,CAAC,oBAAoB;IAwB5B,OAAO,CAAC,cAAc;IAkBtB,OAAO,CAAC,iBAAiB;IAMzB,OAAO,CAAC,0BAA0B;IAMlC;;OAEG;IACH,wBAAwB,CAAC,OAAO,EAAE,UAAU,GAAG,IAAI;IAInD,yEAAyE;IACzE,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAYtC;;;;;;;OAOG;IACH,kBAAkB,CAAC,OAAO,EAAE;QAC1B,MAAM,EAAE,MAAM,GAAG,KAAK,CAAC;QACvB,OAAO,EAAE,MAAM,CAAC;QAChB,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,GAAG,WAAW;IAcf;;OAEG;IACH,KAAK,IAAI,IAAI;CAmBd"}
@@ -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,6 +156,13 @@ 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()}`;
@@ -152,7 +185,8 @@ export class MessageHandler extends EventEmitter {
152
185
  const sameSpeaker = (msg) => !!msg && msg.membershipId === attribution.membershipId;
153
186
  // Handle different message types
154
187
  switch (messageData.type) {
155
- // 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.
156
190
  case "user_text_message":
157
191
  case "user-llm-text":
158
192
  case "text-input":
@@ -161,7 +195,7 @@ export class MessageHandler extends EventEmitter {
161
195
  case "chat-message":
162
196
  case "input-text":
163
197
  case "message":
164
- // Skip user text messages - only allow user-transcription
198
+ // Skip server echoes; local text and voice transcription are handled separately.
165
199
  break;
166
200
  // Bot Ready - Bot is ready, connection should be true
167
201
  case "bot-ready":
@@ -177,7 +211,19 @@ export class MessageHandler extends EventEmitter {
177
211
  this.isBotResponding = true;
178
212
  this.emit("botRespondingChange", true);
179
213
  this.seenBotOutputSegments.clear();
180
- // 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.
181
227
  const streamingMessage = {
182
228
  id: messageId,
183
229
  type: "bot-llm-text",
@@ -253,53 +299,189 @@ export class MessageHandler extends EventEmitter {
253
299
  case "bot-llm-text":
254
300
  if (messageData.data?.text) {
255
301
  const newChunk = messageData.data.text;
256
- // Update the streaming message in chat
257
- const activeIndex = this.activeBotLlmMessageId
258
- ? this.chatMessages.findIndex((message) => message.id === this.activeBotLlmMessageId &&
259
- message.type === "bot-llm-text" &&
260
- message.isStreaming &&
261
- sameSpeaker(message))
262
- : -1;
263
- if (activeIndex >= 0) {
264
- const activeMessage = this.chatMessages[activeIndex];
265
- this.chatMessages[activeIndex] = {
266
- ...activeMessage,
267
- 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
+ : {}),
268
416
  };
417
+ this.chatMessages[targetMessageIndex] = updatedMessage;
269
418
  }
270
419
  else {
271
420
  // If no streaming message exists yet, create one
272
- const streamingMsg = {
421
+ updatedMessage = {
273
422
  id: `${messageData.type}-${Date.now()}-${Math.random()}`,
274
423
  type: "bot-llm-text",
275
424
  content: newChunk,
276
425
  timestamp: new Date().toISOString(),
277
426
  isStreaming: true,
427
+ ...(outputId ? { outputId } : {}),
428
+ ...(logicalTurnId ? { logicalTurnId } : {}),
278
429
  ...attribution,
279
430
  };
280
- this.activeBotLlmMessageId = streamingMsg.id;
281
- this.chatMessages.push(streamingMsg);
431
+ this.activeBotLlmMessageId = updatedMessage.id;
432
+ this.chatMessages.push(updatedMessage);
282
433
  }
283
- this.emit("messagesChange", this.chatMessages);
434
+ this.emit("messagesChange", this.chatMessages, updatedMessage);
284
435
  }
285
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;
286
442
  // Bot LLM Stopped - Complete streaming response
287
- case "bot-llm-stopped":
288
- const activeIndex = this.activeBotLlmMessageId
289
- ? this.chatMessages.findIndex((message) => message.id === this.activeBotLlmMessageId)
290
- : -1;
291
- if (activeIndex >= 0 && this.chatMessages[activeIndex].isStreaming) {
292
- const activeMessage = this.chatMessages[activeIndex];
293
- this.chatMessages[activeIndex] = {
294
- ...activeMessage,
295
- isStreaming: false,
296
- };
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
+ }
297
476
  }
298
477
  // Clear streaming state
299
478
  this.isBotResponding = false;
300
479
  this.emit("botRespondingChange", false);
301
- this.emit("messagesChange", this.chatMessages);
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);
302
483
  break;
484
+ }
303
485
  // Text-only Interactions API terminal marker. This is deliberately a
304
486
  // separate event: a completed REST turn must not be represented as a
305
487
  // synthetic bot-llm-stopped message.
@@ -323,8 +505,17 @@ export class MessageHandler extends EventEmitter {
323
505
  if (messageData.data?.text) {
324
506
  const messageContent = messageData.data.text;
325
507
  if (messageData.data?.final) {
508
+ const logicalTurnId = this.transcriptionLogicalTurnId(messageData.data.logical_turn_id);
326
509
  // Final transcription - accumulate in single bubble until bot responds
327
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
+ }
328
519
  // Check if last message is an accumulating user message (isStreaming=true)
329
520
  if (lastUserMsg &&
330
521
  lastUserMsg.type === "user-transcription" &&
@@ -333,6 +524,7 @@ export class MessageHandler extends EventEmitter {
333
524
  const accumulatedContent = lastUserMsg.content.trim() + " " + messageContent.trim();
334
525
  this.chatMessages[this.chatMessages.length - 1] = {
335
526
  ...lastUserMsg,
527
+ ...(logicalTurnId === undefined ? {} : { logicalTurnId }),
336
528
  content: accumulatedContent,
337
529
  timestamp: timestamp,
338
530
  };
@@ -345,6 +537,7 @@ export class MessageHandler extends EventEmitter {
345
537
  content: messageContent,
346
538
  timestamp: timestamp,
347
539
  isStreaming: true,
540
+ ...(logicalTurnId === undefined ? {} : { logicalTurnId }),
348
541
  };
349
542
  this.chatMessages.push(userMessage);
350
543
  }
@@ -468,15 +661,30 @@ export class MessageHandler extends EventEmitter {
468
661
  if (messageData.data) {
469
662
  const serverData = messageData.data;
470
663
  switch (serverData.type) {
664
+ case "bot-llm-text-raw":
665
+ this._handleBotLlmTextRaw(serverData.data ?? serverData);
666
+ break;
471
667
  case "final-user-transcription":
472
668
  if (serverData.text) {
473
669
  const finalText = serverData.text;
670
+ const logicalTurnId = this.transcriptionLogicalTurnId(serverData.logical_turn_id);
474
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
+ }
475
680
  if (lastUserMsg &&
476
681
  lastUserMsg.type === "user-transcription" &&
477
682
  lastUserMsg.isStreaming === true) {
478
683
  this.chatMessages[this.chatMessages.length - 1] = {
479
684
  ...lastUserMsg,
685
+ ...(logicalTurnId === undefined
686
+ ? {}
687
+ : { logicalTurnId }),
480
688
  content: finalText,
481
689
  timestamp,
482
690
  isStreaming: false,
@@ -489,6 +697,9 @@ export class MessageHandler extends EventEmitter {
489
697
  content: finalText,
490
698
  timestamp,
491
699
  isStreaming: false,
700
+ ...(logicalTurnId === undefined
701
+ ? {}
702
+ : { logicalTurnId }),
492
703
  });
493
704
  }
494
705
  this.emit("messagesChange", this.chatMessages);
@@ -659,6 +870,10 @@ export class MessageHandler extends EventEmitter {
659
870
  }
660
871
  }
661
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;
662
877
  this.emit("actionResponse", { actions });
663
878
  const label = actions.length === 0
664
879
  ? "No action"
@@ -672,6 +887,66 @@ export class MessageHandler extends EventEmitter {
672
887
  this.chatMessages.push(actionMessage);
673
888
  this.emit("messagesChange", this.chatMessages);
674
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
+ }
675
950
  lifecycleOwner(data) {
676
951
  if (!data) {
677
952
  return {};
@@ -689,12 +964,30 @@ export class MessageHandler extends EventEmitter {
689
964
  ? value
690
965
  : undefined;
691
966
  }
967
+ transcriptionLogicalTurnId(value) {
968
+ return this.modelOutputVersion === 2 && isValidLogicalTurnId(value)
969
+ ? value
970
+ : undefined;
971
+ }
692
972
  /**
693
973
  * Public entry point for WebSocket transport to inject messages into the same pipeline.
694
974
  */
695
975
  handleDataReceivedPublic(payload) {
696
976
  this.handleDataReceived(payload, null, "websocket");
697
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
+ }
698
991
  /**
699
992
  * Insert a message into the transcript locally. Nothing is transmitted and
700
993
  * no LLM turn is billed -- this is for client-side lines (a greeting, a
@@ -728,6 +1021,8 @@ export class MessageHandler extends EventEmitter {
728
1021
  this.isListening = false;
729
1022
  this.totalBlendshapesReceived = 0;
730
1023
  this._currentEmotion = null;
1024
+ this.seenBotOutputSegments.clear();
1025
+ this._clearModelOutputReplayLedger();
731
1026
  this.blendshapeQueue.reset();
732
1027
  this.emit("messagesChange", this.chatMessages);
733
1028
  this.emit("userTranscriptionChange", "");