@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/react.cjs CHANGED
@@ -171,9 +171,58 @@ function initialSnapshot() {
171
171
  error: null,
172
172
  errorDetails: void 0,
173
173
  interruptionMode: "none",
174
- canCancel: false
174
+ canCancel: false,
175
+ artifacts: [],
176
+ session: null
175
177
  };
176
178
  }
179
+ function readString(value) {
180
+ return typeof value === "string" && value ? value : void 0;
181
+ }
182
+ function readArtifactFile(value) {
183
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
184
+ const file = value;
185
+ const path = readString(file.path);
186
+ const mimeType = readString(file.mimeType);
187
+ if (!path || !mimeType) return void 0;
188
+ const language = readString(file.language);
189
+ return { path, mimeType, ...language ? { language } : {} };
190
+ }
191
+ function applyArtifactFrame(existing, frame, identity) {
192
+ if (frame.type === "artifact_start") {
193
+ const title = readString(frame.title);
194
+ const component = readString(frame.component);
195
+ const file = readArtifactFile(frame.file);
196
+ return {
197
+ ...identity,
198
+ artifactType: frame.artifactType === "component" ? "component" : "markdown",
199
+ ...title ? { title } : {},
200
+ ...file ? { file } : {},
201
+ ...component ? { component } : {},
202
+ content: "",
203
+ status: "streaming"
204
+ };
205
+ }
206
+ if (!existing) return void 0;
207
+ if (frame.type === "artifact_delta") {
208
+ const delta = typeof frame.delta === "string" ? frame.delta : "";
209
+ return delta ? { ...existing, content: existing.content + delta } : void 0;
210
+ }
211
+ if (frame.type === "artifact_update") {
212
+ const component = readString(frame.component) ?? existing.component;
213
+ const props = frame.props && typeof frame.props === "object" && !Array.isArray(frame.props) ? frame.props : existing.props;
214
+ return {
215
+ ...existing,
216
+ ...component ? { component } : {},
217
+ ...props ? { props } : {}
218
+ };
219
+ }
220
+ if (frame.type === "artifact_complete") return { ...existing, status: "complete" };
221
+ return void 0;
222
+ }
223
+ function readTurnId(msg) {
224
+ return typeof msg.turnId === "string" && msg.turnId ? msg.turnId : null;
225
+ }
177
226
  var VoiceClient = class {
178
227
  constructor(options) {
179
228
  this.options = options;
@@ -194,6 +243,16 @@ var VoiceClient = class {
194
243
  stoppedResponse = false;
195
244
  responseEnded = true;
196
245
  hasPendingAudio = false;
246
+ /**
247
+ * The assistant entry built from clause captions for the turn being spoken. The
248
+ * authoritative transcript replaces it; a stop commits it, because it captions
249
+ * audio the caller already heard.
250
+ */
251
+ pendingAssistant = null;
252
+ /** Turn whose clause captions were already committed by a stop, so its authoritative text is redundant. */
253
+ committedAssistantTurnId = null;
254
+ activeTurnId = null;
255
+ suppressedArtifactTurnId = null;
197
256
  getSnapshot = () => this.snapshot;
198
257
  subscribe = (listener) => {
199
258
  this.listeners.add(listener);
@@ -215,7 +274,7 @@ var VoiceClient = class {
215
274
  if (this.snapshot.status !== "idle" && this.snapshot.status !== "error") return;
216
275
  this.cleanup();
217
276
  const generation = this.generation;
218
- this.update({ ...initialSnapshot(), status: "connecting" });
277
+ this.update({ ...initialSnapshot(), session: this.snapshot.session, status: "connecting" });
219
278
  try {
220
279
  const token = tokenOverride ?? (typeof this.options.clientToken === "function" ? await this.options.clientToken() : this.options.clientToken);
221
280
  if (generation !== this.generation) return;
@@ -230,6 +289,20 @@ var VoiceClient = class {
230
289
  url.pathname = `${basePath}/ws/agents/${encodeURIComponent(this.options.agentId)}/voice`;
231
290
  url.search = "";
232
291
  url.searchParams.set("voiceProtocol", "runtype-browser-v1");
292
+ const capabilities = this.options.artifacts ? "partial_transcript,artifacts" : "partial_transcript";
293
+ url.searchParams.set("clientCapabilities", capabilities);
294
+ const sessionId = this.options.sessionId ?? this.snapshot.session?.sessionId;
295
+ if (sessionId) url.searchParams.set("sessionId", sessionId);
296
+ const visitorToken = typeof this.options.visitorToken === "function" ? await this.options.visitorToken() : this.options.visitorToken;
297
+ if (generation !== this.generation) return;
298
+ if (this.options.visitorToken !== void 0 && !visitorToken) {
299
+ throw new Error(
300
+ "Voice visitor credential unavailable. Initialize the client session first."
301
+ );
302
+ }
303
+ if (visitorToken && !/^cvt_[A-Za-z0-9_-]+$/.test(visitorToken)) {
304
+ throw new Error("Invalid voice visitor credential.");
305
+ }
233
306
  url.hash = "";
234
307
  const stream = await navigator.mediaDevices.getUserMedia({
235
308
  audio: { sampleRate: CAPTURE_SAMPLE_RATE, channelCount: 1, echoCancellation: true }
@@ -253,7 +326,11 @@ var VoiceClient = class {
253
326
  return;
254
327
  }
255
328
  this.player = player;
256
- const socket = new WebSocket(url.toString(), ["runtype.bearer", token]);
329
+ const socket = new WebSocket(url.toString(), [
330
+ "runtype.bearer",
331
+ token,
332
+ ...visitorToken ? [visitorToken] : []
333
+ ]);
257
334
  socket.binaryType = "arraybuffer";
258
335
  this.socket = socket;
259
336
  socket.onopen = () => {
@@ -284,6 +361,11 @@ var VoiceClient = class {
284
361
  this.cleanup();
285
362
  this.update({ status: "idle", interimTranscript: null, audioLevel: 0, isMuted: false });
286
363
  };
364
+ /** Forget the remembered conversation so the next startCall opens a new one. No-op during a call. */
365
+ resetSession = () => {
366
+ if (this.snapshot.status !== "idle" && this.snapshot.status !== "error") return;
367
+ this.update({ session: null });
368
+ };
287
369
  toggleMute = () => {
288
370
  this.update({ isMuted: !this.snapshot.isMuted, audioLevel: 0 });
289
371
  };
@@ -298,9 +380,11 @@ var VoiceClient = class {
298
380
  this.clearPlayback();
299
381
  if (this.snapshot.interruptionMode === "none") {
300
382
  this.stoppedResponse = !this.responseEnded;
383
+ this.commitClauseCaptions();
301
384
  this.setStatus("listening");
302
385
  return;
303
386
  }
387
+ this.suppressedArtifactTurnId = this.activeTurnId;
304
388
  this.awaitingClear = true;
305
389
  this.setStatus("listening");
306
390
  this.socket.send(JSON.stringify({ type: "cancel" }));
@@ -342,6 +426,82 @@ var VoiceClient = class {
342
426
  this.awaitingClear = false;
343
427
  this.stoppedResponse = false;
344
428
  this.responseEnded = true;
429
+ this.commitClauseCaptions();
430
+ this.committedAssistantTurnId = null;
431
+ this.activeTurnId = null;
432
+ this.suppressedArtifactTurnId = null;
433
+ const settledArtifacts = this.snapshot.artifacts.filter(
434
+ (artifact) => artifact.status === "complete"
435
+ );
436
+ if (settledArtifacts.length !== this.snapshot.artifacts.length)
437
+ this.update({ artifacts: settledArtifacts });
438
+ }
439
+ /**
440
+ * Folds an `artifact` message into the snapshot. Frames of a turn the caller
441
+ * cancelled are dropped; a playback-only stop leaves the turn running, so its
442
+ * artifacts keep arriving and are kept.
443
+ */
444
+ applyArtifactMessage(msg) {
445
+ if (this.awaitingClear) return;
446
+ const frame = msg.event;
447
+ if (!frame || typeof frame !== "object" || Array.isArray(frame)) return;
448
+ const event = frame;
449
+ const id = readString(event.id);
450
+ if (!id) return;
451
+ const turnId = readTurnId(msg);
452
+ if (turnId !== null && turnId === this.suppressedArtifactTurnId) return;
453
+ if (turnId !== null) this.activeTurnId = turnId;
454
+ const artifacts = this.snapshot.artifacts;
455
+ const index = artifacts.findIndex((artifact) => artifact.id === id);
456
+ const executionId = readString(msg.executionId);
457
+ const next = applyArtifactFrame(index === -1 ? void 0 : artifacts[index], event, {
458
+ id,
459
+ turnId,
460
+ ...executionId ? { executionId } : {}
461
+ });
462
+ if (!next) return;
463
+ this.update({
464
+ artifacts: index === -1 ? [...artifacts, next] : [...artifacts.slice(0, index), next, ...artifacts.slice(index + 1)]
465
+ });
466
+ }
467
+ /** Settles the spoken clauses in place and ends the turn's claim on them. */
468
+ commitClauseCaptions() {
469
+ this.committedAssistantTurnId = this.pendingAssistant?.turnId ?? null;
470
+ const settled = this.pendingAssistant !== null;
471
+ this.pendingAssistant = null;
472
+ if (!settled) return;
473
+ const transcript = this.snapshot.transcript;
474
+ const last = transcript[transcript.length - 1];
475
+ if (last?.role !== "assistant" || last.partial !== true) return;
476
+ const { partial: _partial, ...committed } = last;
477
+ this.update({ transcript: [...transcript.slice(0, -1), committed] });
478
+ }
479
+ /** Extends the turn's spoken-so-far assistant entry, or opens one for a new turn. */
480
+ appendClauseCaption(text, turnId) {
481
+ const transcript = this.snapshot.transcript;
482
+ const last = transcript[transcript.length - 1];
483
+ if (this.pendingAssistant?.turnId === turnId && last?.role === "assistant") {
484
+ return {
485
+ interimTranscript: null,
486
+ transcript: [...transcript.slice(0, -1), { ...last, content: last.content + text }],
487
+ status: "speaking"
488
+ };
489
+ }
490
+ this.pendingAssistant = { turnId };
491
+ return {
492
+ interimTranscript: null,
493
+ transcript: [
494
+ ...transcript,
495
+ {
496
+ role: "assistant",
497
+ content: text,
498
+ timestamp: Date.now(),
499
+ partial: true,
500
+ ...turnId ? { turnId } : {}
501
+ }
502
+ ],
503
+ status: "speaking"
504
+ };
345
505
  }
346
506
  clearPlayback() {
347
507
  this.playbackRevision += 1;
@@ -376,32 +536,61 @@ var VoiceClient = class {
376
536
  return;
377
537
  }
378
538
  switch (msg.type) {
379
- case "session_config":
539
+ case "session_config": {
380
540
  if (msg.interruptionMode === "none" || msg.interruptionMode === "cancel" || msg.interruptionMode === "barge-in") {
381
541
  this.update({ interruptionMode: msg.interruptionMode });
382
542
  }
543
+ const sessionId = readString(msg.sessionId);
544
+ const conversationId = readString(msg.conversationId);
545
+ if (sessionId && conversationId) {
546
+ const session = { sessionId, conversationId };
547
+ this.update({ session });
548
+ this.options.onSession?.(session);
549
+ }
550
+ break;
551
+ }
552
+ case "artifact":
553
+ this.applyArtifactMessage(msg);
383
554
  break;
384
555
  case "transcript_interim":
385
556
  this.update({ interimTranscript: typeof msg.text === "string" ? msg.text || null : null });
386
557
  break;
387
- case "transcript_final":
558
+ case "transcript_partial": {
559
+ if (msg.role !== "assistant" || typeof msg.text !== "string" || !msg.text) break;
560
+ this.activeTurnId = readTurnId(msg) ?? this.activeTurnId;
561
+ if (this.awaitingClear || this.stoppedResponse) break;
562
+ this.responseEnded = false;
563
+ this.update(this.appendClauseCaption(msg.text, readTurnId(msg)));
564
+ break;
565
+ }
566
+ case "transcript_final": {
388
567
  if (typeof msg.text !== "string" || msg.role !== "user" && msg.role !== "assistant") break;
389
- if (msg.role === "assistant" && (this.awaitingClear || this.stoppedResponse)) break;
568
+ const role = msg.role;
569
+ const text = msg.text;
570
+ if (role === "assistant" && (this.awaitingClear || this.stoppedResponse)) break;
571
+ const turnId = readTurnId(msg);
572
+ if (turnId !== null) this.activeTurnId = turnId;
573
+ if (role === "assistant" && turnId !== null && turnId === this.committedAssistantTurnId)
574
+ break;
390
575
  this.responseEnded = false;
576
+ const transcript = this.snapshot.transcript;
577
+ const last = transcript[transcript.length - 1];
578
+ const supersedes = role === "assistant" && this.pendingAssistant !== null && last?.role === "assistant" && (turnId === null || this.pendingAssistant.turnId === null || this.pendingAssistant.turnId === turnId);
579
+ this.pendingAssistant = null;
580
+ const entry = {
581
+ role,
582
+ content: text,
583
+ // INVARIANT: reuse the caption's timestamp so a keyed list does not remount the bubble.
584
+ timestamp: supersedes && last ? last.timestamp : Date.now(),
585
+ ...turnId ? { turnId } : {}
586
+ };
391
587
  this.update({
392
588
  interimTranscript: null,
393
- transcript: [
394
- ...this.snapshot.transcript,
395
- {
396
- role: msg.role,
397
- content: msg.text,
398
- timestamp: Date.now(),
399
- ...typeof msg.turnId === "string" && msg.turnId ? { turnId: msg.turnId } : {}
400
- }
401
- ],
402
- status: this.awaitingClear ? this.snapshot.status : msg.role === "user" ? "thinking" : "speaking"
589
+ transcript: supersedes ? [...transcript.slice(0, -1), entry] : [...transcript, entry],
590
+ status: this.awaitingClear ? this.snapshot.status : role === "user" ? "thinking" : "speaking"
403
591
  });
404
592
  break;
593
+ }
405
594
  case "audio_end": {
406
595
  this.responseEnded = true;
407
596
  if (this.stoppedResponse) {
@@ -418,10 +607,12 @@ var VoiceClient = class {
418
607
  break;
419
608
  }
420
609
  case "audio_clear":
610
+ this.suppressedArtifactTurnId = this.activeTurnId ?? this.suppressedArtifactTurnId;
421
611
  this.clearPlayback();
422
612
  this.awaitingClear = false;
423
613
  this.stoppedResponse = false;
424
614
  this.responseEnded = true;
615
+ this.commitClauseCaptions();
425
616
  this.setStatus("listening");
426
617
  break;
427
618
  case "metrics": {
@@ -434,8 +625,11 @@ var VoiceClient = class {
434
625
  // @snake-case-ok: Existing voice wire contract.
435
626
  firstAudioMs: number(msg.first_audio_ms),
436
627
  // @snake-case-ok: Existing voice wire contract.
437
- totalMs: number(msg.total_ms)
628
+ totalMs: number(msg.total_ms),
629
+ // @snake-case-ok: Existing voice wire contract.
630
+ firstSynthesisMs: number(msg.first_synthesis_ms),
438
631
  // @snake-case-ok: Existing voice wire contract.
632
+ incremental: msg.incremental === true
439
633
  }
440
634
  });
441
635
  break;
@@ -490,25 +684,68 @@ var VoiceClient = class {
490
684
  };
491
685
 
492
686
  // src/react.ts
493
- function useVoiceClient({ agentId, apiUrl, clientToken }) {
687
+ function useVoiceClient({
688
+ agentId,
689
+ apiUrl,
690
+ clientToken,
691
+ artifacts,
692
+ sessionId,
693
+ visitorToken,
694
+ onSession
695
+ }) {
696
+ const sessionRef = (0, import_react.useRef)(sessionId);
697
+ (0, import_react.useEffect)(() => {
698
+ sessionRef.current = sessionId;
699
+ }, [sessionId]);
494
700
  const tokenRef = (0, import_react.useRef)(clientToken);
701
+ const visitorRef = (0, import_react.useRef)(visitorToken);
702
+ const sessionCallbackRef = (0, import_react.useRef)(onSession);
703
+ (0, import_react.useEffect)(() => {
704
+ visitorRef.current = visitorToken;
705
+ sessionCallbackRef.current = onSession;
706
+ }, [visitorToken, onSession]);
495
707
  (0, import_react.useEffect)(() => {
496
708
  tokenRef.current = clientToken;
497
709
  }, [clientToken]);
498
- const client = (0, import_react.useMemo)(
499
- () => new VoiceClient({
710
+ const createClient = () => new VoiceClient({
711
+ agentId,
712
+ apiUrl,
713
+ artifacts,
714
+ get sessionId() {
715
+ return sessionRef.current;
716
+ },
717
+ get visitorToken() {
718
+ return visitorRef.current;
719
+ },
720
+ onSession: (session) => sessionCallbackRef.current?.(session),
721
+ clientToken: () => typeof tokenRef.current === "function" ? tokenRef.current() : tokenRef.current
722
+ });
723
+ const [binding, setBinding] = (0, import_react.useState)(() => ({
724
+ agentId,
725
+ apiUrl,
726
+ artifacts,
727
+ sessionId,
728
+ client: createClient()
729
+ }));
730
+ const configurationChanged = binding.agentId !== agentId || binding.apiUrl !== apiUrl || binding.artifacts !== artifacts;
731
+ if (configurationChanged || binding.sessionId !== sessionId) {
732
+ const acknowledgesSession = sessionId !== void 0 && sessionId === binding.client.getSnapshot().session?.sessionId;
733
+ setBinding({
500
734
  agentId,
501
735
  apiUrl,
502
- clientToken: () => typeof tokenRef.current === "function" ? tokenRef.current() : tokenRef.current
503
- }),
504
- [agentId, apiUrl]
505
- );
736
+ artifacts,
737
+ sessionId,
738
+ client: !configurationChanged && acknowledgesSession ? binding.client : createClient()
739
+ });
740
+ }
741
+ const client = binding.client;
506
742
  const snapshot = (0, import_react.useSyncExternalStore)(client.subscribe, client.getSnapshot, client.getSnapshot);
507
743
  (0, import_react.useEffect)(() => () => client.endCall(), [client]);
508
744
  return {
509
745
  ...snapshot,
510
746
  startCall: client.startCall,
511
747
  endCall: client.endCall,
748
+ resetSession: client.resetSession,
512
749
  toggleMute: client.toggleMute,
513
750
  cancelResponse: client.cancelResponse
514
751
  };
package/dist/react.d.cts CHANGED
@@ -1,10 +1,11 @@
1
- import { V as VoiceClientOptions, c as VoiceStatus, T as TranscriptEntry, b as VoiceMetrics, I as InterruptionMode } from './types-9SfA7oHc.cjs';
2
- export { a as VoiceSnapshot } from './types-9SfA7oHc.cjs';
1
+ import { V as VoiceClientOptions, f as VoiceStatus, T as TranscriptEntry, d as VoiceMetrics, I as InterruptionMode, b as VoiceArtifact, e as VoiceSession } from './types-DaXyPeiV.cjs';
2
+ export { a as VoiceSnapshot } from './types-DaXyPeiV.cjs';
3
3
 
4
4
  /** Subscribe to a voice client and end its call when the component unmounts or changes agent. */
5
- declare function useVoiceClient({ agentId, apiUrl, clientToken }: VoiceClientOptions): {
5
+ declare function useVoiceClient({ agentId, apiUrl, clientToken, artifacts, sessionId, visitorToken, onSession, }: VoiceClientOptions): {
6
6
  startCall: (tokenOverride?: string) => Promise<void>;
7
7
  endCall: () => void;
8
+ resetSession: () => void;
8
9
  toggleMute: () => void;
9
10
  cancelResponse: () => void;
10
11
  status: VoiceStatus;
@@ -22,6 +23,8 @@ declare function useVoiceClient({ agentId, apiUrl, clientToken }: VoiceClientOpt
22
23
  };
23
24
  interruptionMode: InterruptionMode;
24
25
  canCancel: boolean;
26
+ artifacts: readonly VoiceArtifact[];
27
+ session: VoiceSession | null;
25
28
  };
26
29
 
27
- export { VoiceClientOptions, VoiceStatus, useVoiceClient };
30
+ export { VoiceArtifact, VoiceClientOptions, VoiceSession, VoiceStatus, useVoiceClient };
package/dist/react.d.ts CHANGED
@@ -1,10 +1,11 @@
1
- import { V as VoiceClientOptions, c as VoiceStatus, T as TranscriptEntry, b as VoiceMetrics, I as InterruptionMode } from './types-9SfA7oHc.js';
2
- export { a as VoiceSnapshot } from './types-9SfA7oHc.js';
1
+ import { V as VoiceClientOptions, f as VoiceStatus, T as TranscriptEntry, d as VoiceMetrics, I as InterruptionMode, b as VoiceArtifact, e as VoiceSession } from './types-DaXyPeiV.js';
2
+ export { a as VoiceSnapshot } from './types-DaXyPeiV.js';
3
3
 
4
4
  /** Subscribe to a voice client and end its call when the component unmounts or changes agent. */
5
- declare function useVoiceClient({ agentId, apiUrl, clientToken }: VoiceClientOptions): {
5
+ declare function useVoiceClient({ agentId, apiUrl, clientToken, artifacts, sessionId, visitorToken, onSession, }: VoiceClientOptions): {
6
6
  startCall: (tokenOverride?: string) => Promise<void>;
7
7
  endCall: () => void;
8
+ resetSession: () => void;
8
9
  toggleMute: () => void;
9
10
  cancelResponse: () => void;
10
11
  status: VoiceStatus;
@@ -22,6 +23,8 @@ declare function useVoiceClient({ agentId, apiUrl, clientToken }: VoiceClientOpt
22
23
  };
23
24
  interruptionMode: InterruptionMode;
24
25
  canCancel: boolean;
26
+ artifacts: readonly VoiceArtifact[];
27
+ session: VoiceSession | null;
25
28
  };
26
29
 
27
- export { VoiceClientOptions, VoiceStatus, useVoiceClient };
30
+ export { VoiceArtifact, VoiceClientOptions, VoiceSession, VoiceStatus, useVoiceClient };