@runtypelabs/voice 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -143,9 +143,58 @@ function initialSnapshot() {
143
143
  error: null,
144
144
  errorDetails: void 0,
145
145
  interruptionMode: "none",
146
- canCancel: false
146
+ canCancel: false,
147
+ artifacts: [],
148
+ session: null
147
149
  };
148
150
  }
151
+ function readString(value) {
152
+ return typeof value === "string" && value ? value : void 0;
153
+ }
154
+ function readArtifactFile(value) {
155
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
156
+ const file = value;
157
+ const path = readString(file.path);
158
+ const mimeType = readString(file.mimeType);
159
+ if (!path || !mimeType) return void 0;
160
+ const language = readString(file.language);
161
+ return { path, mimeType, ...language ? { language } : {} };
162
+ }
163
+ function applyArtifactFrame(existing, frame, identity) {
164
+ if (frame.type === "artifact_start") {
165
+ const title = readString(frame.title);
166
+ const component = readString(frame.component);
167
+ const file = readArtifactFile(frame.file);
168
+ return {
169
+ ...identity,
170
+ artifactType: frame.artifactType === "component" ? "component" : "markdown",
171
+ ...title ? { title } : {},
172
+ ...file ? { file } : {},
173
+ ...component ? { component } : {},
174
+ content: "",
175
+ status: "streaming"
176
+ };
177
+ }
178
+ if (!existing) return void 0;
179
+ if (frame.type === "artifact_delta") {
180
+ const delta = typeof frame.delta === "string" ? frame.delta : "";
181
+ return delta ? { ...existing, content: existing.content + delta } : void 0;
182
+ }
183
+ if (frame.type === "artifact_update") {
184
+ const component = readString(frame.component) ?? existing.component;
185
+ const props = frame.props && typeof frame.props === "object" && !Array.isArray(frame.props) ? frame.props : existing.props;
186
+ return {
187
+ ...existing,
188
+ ...component ? { component } : {},
189
+ ...props ? { props } : {}
190
+ };
191
+ }
192
+ if (frame.type === "artifact_complete") return { ...existing, status: "complete" };
193
+ return void 0;
194
+ }
195
+ function readTurnId(msg) {
196
+ return typeof msg.turnId === "string" && msg.turnId ? msg.turnId : null;
197
+ }
149
198
  var VoiceClient = class {
150
199
  constructor(options) {
151
200
  this.options = options;
@@ -166,6 +215,16 @@ var VoiceClient = class {
166
215
  stoppedResponse = false;
167
216
  responseEnded = true;
168
217
  hasPendingAudio = false;
218
+ /**
219
+ * The assistant entry built from clause captions for the turn being spoken. The
220
+ * authoritative transcript replaces it; a stop commits it, because it captions
221
+ * audio the caller already heard.
222
+ */
223
+ pendingAssistant = null;
224
+ /** Turn whose clause captions were already committed by a stop, so its authoritative text is redundant. */
225
+ committedAssistantTurnId = null;
226
+ activeTurnId = null;
227
+ suppressedArtifactTurnId = null;
169
228
  getSnapshot = () => this.snapshot;
170
229
  subscribe = (listener) => {
171
230
  this.listeners.add(listener);
@@ -187,7 +246,7 @@ var VoiceClient = class {
187
246
  if (this.snapshot.status !== "idle" && this.snapshot.status !== "error") return;
188
247
  this.cleanup();
189
248
  const generation = this.generation;
190
- this.update({ ...initialSnapshot(), status: "connecting" });
249
+ this.update({ ...initialSnapshot(), session: this.snapshot.session, status: "connecting" });
191
250
  try {
192
251
  const token = tokenOverride ?? (typeof this.options.clientToken === "function" ? await this.options.clientToken() : this.options.clientToken);
193
252
  if (generation !== this.generation) return;
@@ -202,6 +261,20 @@ var VoiceClient = class {
202
261
  url.pathname = `${basePath}/ws/agents/${encodeURIComponent(this.options.agentId)}/voice`;
203
262
  url.search = "";
204
263
  url.searchParams.set("voiceProtocol", "runtype-browser-v1");
264
+ const capabilities = this.options.artifacts ? "partial_transcript,artifacts" : "partial_transcript";
265
+ url.searchParams.set("clientCapabilities", capabilities);
266
+ const sessionId = this.options.sessionId ?? this.snapshot.session?.sessionId;
267
+ if (sessionId) url.searchParams.set("sessionId", sessionId);
268
+ const visitorToken = typeof this.options.visitorToken === "function" ? await this.options.visitorToken() : this.options.visitorToken;
269
+ if (generation !== this.generation) return;
270
+ if (this.options.visitorToken !== void 0 && !visitorToken) {
271
+ throw new Error(
272
+ "Voice visitor credential unavailable. Initialize the client session first."
273
+ );
274
+ }
275
+ if (visitorToken && !/^cvt_[A-Za-z0-9_-]+$/.test(visitorToken)) {
276
+ throw new Error("Invalid voice visitor credential.");
277
+ }
205
278
  url.hash = "";
206
279
  const stream = await navigator.mediaDevices.getUserMedia({
207
280
  audio: { sampleRate: CAPTURE_SAMPLE_RATE, channelCount: 1, echoCancellation: true }
@@ -225,7 +298,11 @@ var VoiceClient = class {
225
298
  return;
226
299
  }
227
300
  this.player = player;
228
- const socket = new WebSocket(url.toString(), ["runtype.bearer", token]);
301
+ const socket = new WebSocket(url.toString(), [
302
+ "runtype.bearer",
303
+ token,
304
+ ...visitorToken ? [visitorToken] : []
305
+ ]);
229
306
  socket.binaryType = "arraybuffer";
230
307
  this.socket = socket;
231
308
  socket.onopen = () => {
@@ -256,6 +333,11 @@ var VoiceClient = class {
256
333
  this.cleanup();
257
334
  this.update({ status: "idle", interimTranscript: null, audioLevel: 0, isMuted: false });
258
335
  };
336
+ /** Forget the remembered conversation so the next startCall opens a new one. No-op during a call. */
337
+ resetSession = () => {
338
+ if (this.snapshot.status !== "idle" && this.snapshot.status !== "error") return;
339
+ this.update({ session: null });
340
+ };
259
341
  toggleMute = () => {
260
342
  this.update({ isMuted: !this.snapshot.isMuted, audioLevel: 0 });
261
343
  };
@@ -270,9 +352,11 @@ var VoiceClient = class {
270
352
  this.clearPlayback();
271
353
  if (this.snapshot.interruptionMode === "none") {
272
354
  this.stoppedResponse = !this.responseEnded;
355
+ this.commitClauseCaptions();
273
356
  this.setStatus("listening");
274
357
  return;
275
358
  }
359
+ this.suppressedArtifactTurnId = this.activeTurnId;
276
360
  this.awaitingClear = true;
277
361
  this.setStatus("listening");
278
362
  this.socket.send(JSON.stringify({ type: "cancel" }));
@@ -314,6 +398,82 @@ var VoiceClient = class {
314
398
  this.awaitingClear = false;
315
399
  this.stoppedResponse = false;
316
400
  this.responseEnded = true;
401
+ this.commitClauseCaptions();
402
+ this.committedAssistantTurnId = null;
403
+ this.activeTurnId = null;
404
+ this.suppressedArtifactTurnId = null;
405
+ const settledArtifacts = this.snapshot.artifacts.filter(
406
+ (artifact) => artifact.status === "complete"
407
+ );
408
+ if (settledArtifacts.length !== this.snapshot.artifacts.length)
409
+ this.update({ artifacts: settledArtifacts });
410
+ }
411
+ /**
412
+ * Folds an `artifact` message into the snapshot. Frames of a turn the caller
413
+ * cancelled are dropped; a playback-only stop leaves the turn running, so its
414
+ * artifacts keep arriving and are kept.
415
+ */
416
+ applyArtifactMessage(msg) {
417
+ if (this.awaitingClear) return;
418
+ const frame = msg.event;
419
+ if (!frame || typeof frame !== "object" || Array.isArray(frame)) return;
420
+ const event = frame;
421
+ const id = readString(event.id);
422
+ if (!id) return;
423
+ const turnId = readTurnId(msg);
424
+ if (turnId !== null && turnId === this.suppressedArtifactTurnId) return;
425
+ if (turnId !== null) this.activeTurnId = turnId;
426
+ const artifacts = this.snapshot.artifacts;
427
+ const index = artifacts.findIndex((artifact) => artifact.id === id);
428
+ const executionId = readString(msg.executionId);
429
+ const next = applyArtifactFrame(index === -1 ? void 0 : artifacts[index], event, {
430
+ id,
431
+ turnId,
432
+ ...executionId ? { executionId } : {}
433
+ });
434
+ if (!next) return;
435
+ this.update({
436
+ artifacts: index === -1 ? [...artifacts, next] : [...artifacts.slice(0, index), next, ...artifacts.slice(index + 1)]
437
+ });
438
+ }
439
+ /** Settles the spoken clauses in place and ends the turn's claim on them. */
440
+ commitClauseCaptions() {
441
+ this.committedAssistantTurnId = this.pendingAssistant?.turnId ?? null;
442
+ const settled = this.pendingAssistant !== null;
443
+ this.pendingAssistant = null;
444
+ if (!settled) return;
445
+ const transcript = this.snapshot.transcript;
446
+ const last = transcript[transcript.length - 1];
447
+ if (last?.role !== "assistant" || last.partial !== true) return;
448
+ const { partial: _partial, ...committed } = last;
449
+ this.update({ transcript: [...transcript.slice(0, -1), committed] });
450
+ }
451
+ /** Extends the turn's spoken-so-far assistant entry, or opens one for a new turn. */
452
+ appendClauseCaption(text, turnId) {
453
+ const transcript = this.snapshot.transcript;
454
+ const last = transcript[transcript.length - 1];
455
+ if (this.pendingAssistant?.turnId === turnId && last?.role === "assistant") {
456
+ return {
457
+ interimTranscript: null,
458
+ transcript: [...transcript.slice(0, -1), { ...last, content: last.content + text }],
459
+ status: "speaking"
460
+ };
461
+ }
462
+ this.pendingAssistant = { turnId };
463
+ return {
464
+ interimTranscript: null,
465
+ transcript: [
466
+ ...transcript,
467
+ {
468
+ role: "assistant",
469
+ content: text,
470
+ timestamp: Date.now(),
471
+ partial: true,
472
+ ...turnId ? { turnId } : {}
473
+ }
474
+ ],
475
+ status: "speaking"
476
+ };
317
477
  }
318
478
  clearPlayback() {
319
479
  this.playbackRevision += 1;
@@ -348,32 +508,61 @@ var VoiceClient = class {
348
508
  return;
349
509
  }
350
510
  switch (msg.type) {
351
- case "session_config":
511
+ case "session_config": {
352
512
  if (msg.interruptionMode === "none" || msg.interruptionMode === "cancel" || msg.interruptionMode === "barge-in") {
353
513
  this.update({ interruptionMode: msg.interruptionMode });
354
514
  }
515
+ const sessionId = readString(msg.sessionId);
516
+ const conversationId = readString(msg.conversationId);
517
+ if (sessionId && conversationId) {
518
+ const session = { sessionId, conversationId };
519
+ this.update({ session });
520
+ this.options.onSession?.(session);
521
+ }
522
+ break;
523
+ }
524
+ case "artifact":
525
+ this.applyArtifactMessage(msg);
355
526
  break;
356
527
  case "transcript_interim":
357
528
  this.update({ interimTranscript: typeof msg.text === "string" ? msg.text || null : null });
358
529
  break;
359
- case "transcript_final":
530
+ case "transcript_partial": {
531
+ if (msg.role !== "assistant" || typeof msg.text !== "string" || !msg.text) break;
532
+ this.activeTurnId = readTurnId(msg) ?? this.activeTurnId;
533
+ if (this.awaitingClear || this.stoppedResponse) break;
534
+ this.responseEnded = false;
535
+ this.update(this.appendClauseCaption(msg.text, readTurnId(msg)));
536
+ break;
537
+ }
538
+ case "transcript_final": {
360
539
  if (typeof msg.text !== "string" || msg.role !== "user" && msg.role !== "assistant") break;
361
- if (msg.role === "assistant" && (this.awaitingClear || this.stoppedResponse)) break;
540
+ const role = msg.role;
541
+ const text = msg.text;
542
+ if (role === "assistant" && (this.awaitingClear || this.stoppedResponse)) break;
543
+ const turnId = readTurnId(msg);
544
+ if (turnId !== null) this.activeTurnId = turnId;
545
+ if (role === "assistant" && turnId !== null && turnId === this.committedAssistantTurnId)
546
+ break;
362
547
  this.responseEnded = false;
548
+ const transcript = this.snapshot.transcript;
549
+ const last = transcript[transcript.length - 1];
550
+ const supersedes = role === "assistant" && this.pendingAssistant !== null && last?.role === "assistant" && (turnId === null || this.pendingAssistant.turnId === null || this.pendingAssistant.turnId === turnId);
551
+ this.pendingAssistant = null;
552
+ const entry = {
553
+ role,
554
+ content: text,
555
+ // INVARIANT: reuse the caption's timestamp so a keyed list does not remount the bubble.
556
+ timestamp: supersedes && last ? last.timestamp : Date.now(),
557
+ ...turnId ? { turnId } : {}
558
+ };
363
559
  this.update({
364
560
  interimTranscript: null,
365
- transcript: [
366
- ...this.snapshot.transcript,
367
- {
368
- role: msg.role,
369
- content: msg.text,
370
- timestamp: Date.now(),
371
- ...typeof msg.turnId === "string" && msg.turnId ? { turnId: msg.turnId } : {}
372
- }
373
- ],
374
- status: this.awaitingClear ? this.snapshot.status : msg.role === "user" ? "thinking" : "speaking"
561
+ transcript: supersedes ? [...transcript.slice(0, -1), entry] : [...transcript, entry],
562
+ status: this.awaitingClear ? this.snapshot.status : role === "user" ? "thinking" : "speaking"
375
563
  });
376
564
  break;
565
+ }
377
566
  case "audio_end": {
378
567
  this.responseEnded = true;
379
568
  if (this.stoppedResponse) {
@@ -390,10 +579,12 @@ var VoiceClient = class {
390
579
  break;
391
580
  }
392
581
  case "audio_clear":
582
+ this.suppressedArtifactTurnId = this.activeTurnId ?? this.suppressedArtifactTurnId;
393
583
  this.clearPlayback();
394
584
  this.awaitingClear = false;
395
585
  this.stoppedResponse = false;
396
586
  this.responseEnded = true;
587
+ this.commitClauseCaptions();
397
588
  this.setStatus("listening");
398
589
  break;
399
590
  case "metrics": {
@@ -406,8 +597,11 @@ var VoiceClient = class {
406
597
  // @snake-case-ok: Existing voice wire contract.
407
598
  firstAudioMs: number(msg.first_audio_ms),
408
599
  // @snake-case-ok: Existing voice wire contract.
409
- totalMs: number(msg.total_ms)
600
+ totalMs: number(msg.total_ms),
601
+ // @snake-case-ok: Existing voice wire contract.
602
+ firstSynthesisMs: number(msg.first_synthesis_ms),
410
603
  // @snake-case-ok: Existing voice wire contract.
604
+ incremental: msg.incremental === true
411
605
  }
412
606
  });
413
607
  break;