@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
|
@@ -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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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" &&
|
|
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
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
:
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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 =
|
|
253
|
-
this.chatMessages.push(
|
|
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
|
-
|
|
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 (
|
|
264
|
-
|
|
265
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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", "");
|