@wibly/sdk 0.1.0 → 0.1.1
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/CHANGELOG.md +19 -2
- package/package.json +4 -4
- package/src/client.ts +265 -33
- package/src/consent.ts +25 -3
- package/src/control.ts +2 -2
- package/src/errors.ts +3 -3
- package/src/index.ts +1 -0
- package/src/inference.ts +27 -19
- package/src/lifecycle.ts +22 -1
- package/src/react.ts +32 -1
- package/src/store.ts +44 -0
- package/src/voice.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
# `@wibly/sdk` — Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.1.1 — 2026-05-30
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- `SdkCallKind` and `SdkQualityTier` are now type-only re-exports of
|
|
8
|
+
`@platform/manifest`'s `CallKind` and `QualityTier` instead of
|
|
9
|
+
hand-duplicated string-literal unions that had drifted from the
|
|
10
|
+
manifest's `CallKindSchema` / `QualityTierSchema`. The previous
|
|
11
|
+
literals included `judge_submissions` and `host_persona_response`
|
|
12
|
+
(neither valid manifest call kinds) and `preview` / `high` quality
|
|
13
|
+
tiers (the manifest set is `fast | standard | premium | creative`);
|
|
14
|
+
any caller using those literals would have failed Gateway request
|
|
15
|
+
validation at the first network hop. Type-only `import` keeps the
|
|
16
|
+
SDK's runtime weight unchanged. The `inference.judge` convenience
|
|
17
|
+
wrapper now sends `callKind: 'host_judge'` (was the invalid
|
|
18
|
+
`judge_submissions`).
|
|
19
|
+
|
|
20
|
+
## 0.1.0 — 2026-05-18
|
|
4
21
|
|
|
5
22
|
Initial Studio SDK surface (chunk B7).
|
|
6
23
|
|
|
@@ -20,7 +37,7 @@ Initial Studio SDK surface (chunk B7).
|
|
|
20
37
|
- `events.{onEvent, onAnyEvent}` and `lifecycle.{onSessionOpened,
|
|
21
38
|
onSessionClosed, onPhaseEntered, onPhaseExited, onHostReclaimed}`.
|
|
22
39
|
- `requestConsent(payload)` to forward an accepted consent decision
|
|
23
|
-
to the User Portal endpoint (chunk
|
|
40
|
+
to the User Portal endpoint (chunk B19a; returns
|
|
24
41
|
`consent_persistence_not_wired` until then).
|
|
25
42
|
- `close()` to tear down the WebSocket + dispose listeners.
|
|
26
43
|
- React bindings at `@wibly/sdk/react`: `SessionProvider`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wibly/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Wibly @wibly/sdk",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
"access": "public"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@wibly/internal-manifest": "0.1.
|
|
22
|
-
"@wibly/internal-protocol": "0.1.
|
|
23
|
-
"@wibly/internal-shared": "0.1.
|
|
21
|
+
"@wibly/internal-manifest": "0.1.1",
|
|
22
|
+
"@wibly/internal-protocol": "0.1.1",
|
|
23
|
+
"@wibly/internal-shared": "0.1.1",
|
|
24
24
|
"zod": "^3.25.76"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
package/src/client.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* **Consent callback.** When the Runtime emits a `consent_required`
|
|
28
28
|
* event, the SDK calls the registered callback and (on
|
|
29
29
|
* `accepted`) calls `session.requestConsent(...)` to forward the
|
|
30
|
-
* grant to the User Portal endpoint (chunk
|
|
30
|
+
* grant to the User Portal endpoint (chunk B19a).
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import { ok, type Result } from '@wibly/internal-shared';
|
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
INFERENCE_EVENT_PREFIX,
|
|
69
69
|
} from './inference.js';
|
|
70
70
|
import {
|
|
71
|
+
estimateAudioDurationMs,
|
|
71
72
|
type SessionVoice,
|
|
72
73
|
type SpeakSuccess,
|
|
73
74
|
VOICE_EVENT_PREFIX,
|
|
@@ -94,6 +95,17 @@ export type SessionConfig = {
|
|
|
94
95
|
readonly wsUrl: string;
|
|
95
96
|
readonly sessionId: SessionId;
|
|
96
97
|
readonly auth?: string;
|
|
98
|
+
/**
|
|
99
|
+
* Marks the connected Session as a preview run (Chunk B16).
|
|
100
|
+
*
|
|
101
|
+
* The Portal/Admin that opens the Session passes this flag when
|
|
102
|
+
* the Runtime's `GET /sessions/:id` returned
|
|
103
|
+
* `visibility === 'preview'` (or the redemption response set
|
|
104
|
+
* `isPreview: true`). Shells use the value to render a "Preview"
|
|
105
|
+
* watermark on host/player chrome and to suppress production-only
|
|
106
|
+
* affordances. Defaults to `false`.
|
|
107
|
+
*/
|
|
108
|
+
readonly isPreview?: boolean;
|
|
97
109
|
readonly onConsentRequired?: ConsentRequiredCallback;
|
|
98
110
|
/** Test seam — pass a fake WebSocket constructor. */
|
|
99
111
|
readonly factory?: WebSocketFactory;
|
|
@@ -107,10 +119,12 @@ export type SessionConfig = {
|
|
|
107
119
|
* Submit confirmation timeout. Default 30s. See `submit.ts`.
|
|
108
120
|
*/
|
|
109
121
|
readonly submitTimeoutMs?: number;
|
|
122
|
+
/** Voice speak confirmation timeout. Default 30s. */
|
|
123
|
+
readonly voiceSpeakTimeoutMs?: number;
|
|
110
124
|
/**
|
|
111
125
|
* Persistence endpoint for accepted consents. When the player
|
|
112
126
|
* accepts a consent prompt, the SDK invokes `persistConsent` to
|
|
113
|
-
* write the grant. Chunk
|
|
127
|
+
* write the grant. Chunk B19a wires the real implementation
|
|
114
128
|
* against the User Portal; MVP leaves it `undefined` and
|
|
115
129
|
* `requestConsent` returns `consent_persistence_not_wired`.
|
|
116
130
|
*/
|
|
@@ -129,10 +143,29 @@ export type SessionState = {
|
|
|
129
143
|
readonly appliedSeq: number;
|
|
130
144
|
/** Current phase id from the most recent snapshot. */
|
|
131
145
|
readonly phaseId: string | null;
|
|
146
|
+
/** Whether the Runtime has broadcast a `lifecycle.paused` frame. */
|
|
147
|
+
readonly sessionPaused: boolean;
|
|
148
|
+
readonly pauseReason: 'host' | 'system' | null;
|
|
149
|
+
/** Host-visible orphan-seat recovery code, when active. */
|
|
150
|
+
readonly recoveryCode: string | null;
|
|
151
|
+
readonly recoveryCodeHint: string | null;
|
|
152
|
+
/**
|
|
153
|
+
* Whether the connected Session is a preview run (Chunk B16).
|
|
154
|
+
* Populated from `SessionConfig.isPreview` and updated when a
|
|
155
|
+
* lifecycle frame with `transition === 'session.metadata'`
|
|
156
|
+
* carries `{ isPreview: boolean }` in its detail.
|
|
157
|
+
*/
|
|
158
|
+
readonly isPreview: boolean;
|
|
132
159
|
};
|
|
133
160
|
|
|
134
161
|
export type Session = {
|
|
135
162
|
readonly sessionId: SessionId;
|
|
163
|
+
/**
|
|
164
|
+
* Whether the Session is a preview run (Chunk B16). Mirrors
|
|
165
|
+
* {@link SessionState.isPreview} so callers that don't need the
|
|
166
|
+
* full state snapshot can read the flag directly.
|
|
167
|
+
*/
|
|
168
|
+
readonly isPreview: boolean;
|
|
136
169
|
/** Current snapshot — calling getter triggers no work. */
|
|
137
170
|
readonly getState: () => SessionState;
|
|
138
171
|
/** Reactive subscription. The callback fires on store changes. */
|
|
@@ -160,7 +193,7 @@ export type Session = {
|
|
|
160
193
|
readonly store: SessionStore;
|
|
161
194
|
/**
|
|
162
195
|
* Forward an accepted consent decision to the persistence path.
|
|
163
|
-
* Returns `consent_persistence_not_wired` until chunk
|
|
196
|
+
* Returns `consent_persistence_not_wired` until chunk B19a binds
|
|
164
197
|
* the User Portal endpoint.
|
|
165
198
|
*/
|
|
166
199
|
readonly requestConsent: (
|
|
@@ -177,20 +210,31 @@ type PendingRecord = {
|
|
|
177
210
|
readonly timer?: () => void;
|
|
178
211
|
};
|
|
179
212
|
|
|
213
|
+
type PendingVoiceRecord = {
|
|
214
|
+
readonly resolve: (r: Result<unknown, SdkError>) => void;
|
|
215
|
+
readonly caption: string | null | undefined;
|
|
216
|
+
readonly cancelTimeout: () => void;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const DEFAULT_VOICE_SPEAK_TIMEOUT_MS = 30_000;
|
|
220
|
+
|
|
180
221
|
export const createSession = (config: SessionConfig): Session => {
|
|
181
222
|
const store = createSessionStore();
|
|
182
223
|
const bus = createEventBus();
|
|
183
224
|
const time = createServerTimeSource({ now: config.now });
|
|
184
225
|
const lifecycle = createLifecycleBindings(bus);
|
|
226
|
+
let isPreviewFlag: boolean = config.isPreview ?? false;
|
|
185
227
|
const pendingSubmits = new Map<MessageId, PendingRecord>();
|
|
186
228
|
const pendingInference = new Map<
|
|
187
229
|
MessageId,
|
|
188
230
|
(r: Result<unknown, SdkError>) => void
|
|
189
231
|
>();
|
|
190
|
-
const pendingVoice = new Map<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
232
|
+
const pendingVoice = new Map<MessageId, PendingVoiceRecord>();
|
|
233
|
+
const voiceSpeakTimeoutMs =
|
|
234
|
+
config.voiceSpeakTimeoutMs ?? DEFAULT_VOICE_SPEAK_TIMEOUT_MS;
|
|
235
|
+
const scheduleTimer =
|
|
236
|
+
config.schedule ??
|
|
237
|
+
((cb: () => void, ms: number) => scheduleTimeout(ms, cb));
|
|
194
238
|
|
|
195
239
|
const handleStateDiff = (payload: StateDiffPayload): void => {
|
|
196
240
|
const action = store.applyDiff(
|
|
@@ -209,6 +253,18 @@ export const createSession = (config: SessionConfig): Session => {
|
|
|
209
253
|
|
|
210
254
|
const handleEvent = (payload: EventPayload): void => {
|
|
211
255
|
bus.dispatchEvent(payload);
|
|
256
|
+
if (payload.eventType === 'recovery_code.issued') {
|
|
257
|
+
const data = payload.data as {
|
|
258
|
+
code?: unknown;
|
|
259
|
+
seat?: unknown;
|
|
260
|
+
} | null;
|
|
261
|
+
if (data && typeof data.code === 'string') {
|
|
262
|
+
store.setRecoveryCode(
|
|
263
|
+
data.code,
|
|
264
|
+
typeof data.seat === 'string' ? data.seat : null,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
212
268
|
if (payload.eventType.startsWith(INFERENCE_EVENT_PREFIX)) {
|
|
213
269
|
maybeResolveInference(payload);
|
|
214
270
|
} else if (payload.eventType.startsWith(VOICE_EVENT_PREFIX)) {
|
|
@@ -219,6 +275,58 @@ export const createSession = (config: SessionConfig): Session => {
|
|
|
219
275
|
};
|
|
220
276
|
|
|
221
277
|
const handleLifecycle = (payload: LifecyclePayload): void => {
|
|
278
|
+
if (
|
|
279
|
+
payload.transition === 'session.metadata' &&
|
|
280
|
+
payload.detail !== null &&
|
|
281
|
+
typeof payload.detail === 'object' &&
|
|
282
|
+
'isPreview' in payload.detail &&
|
|
283
|
+
typeof (payload.detail as { isPreview: unknown }).isPreview === 'boolean'
|
|
284
|
+
) {
|
|
285
|
+
isPreviewFlag = (payload.detail as { isPreview: boolean }).isPreview;
|
|
286
|
+
cachedSessionState = null;
|
|
287
|
+
}
|
|
288
|
+
if (
|
|
289
|
+
payload.transition === 'session.closed' ||
|
|
290
|
+
payload.transition === 'session.aborted' ||
|
|
291
|
+
payload.transition === 'aborted'
|
|
292
|
+
) {
|
|
293
|
+
transport.close(1000, 'session ended');
|
|
294
|
+
}
|
|
295
|
+
if (payload.transition === 'phase.changed') {
|
|
296
|
+
const detail = payload.detail;
|
|
297
|
+
if (detail !== null && typeof detail === 'object') {
|
|
298
|
+
const toPhaseId = (detail as { toPhaseId?: unknown }).toPhaseId;
|
|
299
|
+
if (typeof toPhaseId === 'string') {
|
|
300
|
+
store.setPhaseId(toPhaseId);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (payload.transition === 'paused') {
|
|
305
|
+
const detail = payload.detail;
|
|
306
|
+
const via =
|
|
307
|
+
detail !== null &&
|
|
308
|
+
typeof detail === 'object' &&
|
|
309
|
+
(detail as { via?: unknown }).via === 'host_control'
|
|
310
|
+
? 'host'
|
|
311
|
+
: 'system';
|
|
312
|
+
store.setSessionPaused(true, via);
|
|
313
|
+
}
|
|
314
|
+
if (payload.transition === 'continued') {
|
|
315
|
+
store.setSessionPaused(false, null);
|
|
316
|
+
}
|
|
317
|
+
if (payload.transition === 'seat.recovery_code_issued') {
|
|
318
|
+
const detail = payload.detail;
|
|
319
|
+
if (detail !== null && typeof detail === 'object') {
|
|
320
|
+
const code = (detail as { code?: unknown }).code;
|
|
321
|
+
const seat = (detail as { seat?: unknown }).seat;
|
|
322
|
+
if (typeof code === 'string') {
|
|
323
|
+
store.setRecoveryCode(
|
|
324
|
+
code,
|
|
325
|
+
typeof seat === 'string' ? seat : null,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
222
330
|
bus.dispatchLifecycle(payload);
|
|
223
331
|
};
|
|
224
332
|
|
|
@@ -261,9 +369,10 @@ export const createSession = (config: SessionConfig): Session => {
|
|
|
261
369
|
}
|
|
262
370
|
const voice = pendingVoice.get(causeId);
|
|
263
371
|
if (voice !== undefined) {
|
|
372
|
+
voice.cancelTimeout();
|
|
264
373
|
pendingVoice.delete(causeId);
|
|
265
374
|
transport.confirmSend(causeId);
|
|
266
|
-
voice({
|
|
375
|
+
voice.resolve({
|
|
267
376
|
ok: false,
|
|
268
377
|
error: {
|
|
269
378
|
kind: 'submit_rejected',
|
|
@@ -366,15 +475,94 @@ export const createSession = (config: SessionConfig): Session => {
|
|
|
366
475
|
cb(ok(payload.data));
|
|
367
476
|
};
|
|
368
477
|
|
|
478
|
+
const dispatchVoiceAudio = (
|
|
479
|
+
causeId: MessageId,
|
|
480
|
+
data: {
|
|
481
|
+
audioBase64: string;
|
|
482
|
+
contentType?: unknown;
|
|
483
|
+
durationMs?: unknown;
|
|
484
|
+
},
|
|
485
|
+
caption: string | null | undefined,
|
|
486
|
+
): void => {
|
|
487
|
+
bus.dispatchEvent({
|
|
488
|
+
sessionId: config.sessionId,
|
|
489
|
+
eventType: 'voice.audio',
|
|
490
|
+
data: {
|
|
491
|
+
id: causeId,
|
|
492
|
+
audioBase64: data.audioBase64,
|
|
493
|
+
contentType:
|
|
494
|
+
typeof data.contentType === 'string'
|
|
495
|
+
? data.contentType
|
|
496
|
+
: 'audio/mpeg',
|
|
497
|
+
durationMs:
|
|
498
|
+
typeof data.durationMs === 'number' && data.durationMs > 0
|
|
499
|
+
? data.durationMs
|
|
500
|
+
: estimateAudioDurationMs(data.audioBase64),
|
|
501
|
+
caption: caption === undefined ? null : caption,
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
};
|
|
505
|
+
|
|
369
506
|
const maybeResolveVoice = (payload: EventPayload): void => {
|
|
370
|
-
const data = payload.data as {
|
|
507
|
+
const data = payload.data as {
|
|
508
|
+
causeMessageId?: string;
|
|
509
|
+
audioBase64?: string;
|
|
510
|
+
contentType?: string;
|
|
511
|
+
durationMs?: number;
|
|
512
|
+
kind?: string;
|
|
513
|
+
} | null;
|
|
371
514
|
if (!data || typeof data.causeMessageId !== 'string') return;
|
|
372
515
|
const causeId = data.causeMessageId as unknown as MessageId;
|
|
373
|
-
const
|
|
374
|
-
|
|
516
|
+
const pending = pendingVoice.get(causeId);
|
|
517
|
+
const caption = pending?.caption;
|
|
518
|
+
const audioBase64 = data.audioBase64;
|
|
519
|
+
|
|
520
|
+
if (
|
|
521
|
+
payload.eventType === 'voice.speak.result' &&
|
|
522
|
+
typeof audioBase64 === 'string'
|
|
523
|
+
) {
|
|
524
|
+
dispatchVoiceAudio(
|
|
525
|
+
causeId,
|
|
526
|
+
{
|
|
527
|
+
audioBase64,
|
|
528
|
+
contentType: data.contentType,
|
|
529
|
+
durationMs: data.durationMs,
|
|
530
|
+
},
|
|
531
|
+
caption ?? null,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (pending === undefined) return;
|
|
536
|
+
pending.cancelTimeout();
|
|
375
537
|
pendingVoice.delete(causeId);
|
|
376
538
|
transport.confirmSend(causeId);
|
|
377
|
-
|
|
539
|
+
|
|
540
|
+
if (
|
|
541
|
+
payload.eventType === 'voice.speak.result' &&
|
|
542
|
+
typeof audioBase64 === 'string'
|
|
543
|
+
) {
|
|
544
|
+
pending.resolve(
|
|
545
|
+
ok({
|
|
546
|
+
audioBase64,
|
|
547
|
+
contentType:
|
|
548
|
+
typeof data.contentType === 'string'
|
|
549
|
+
? data.contentType
|
|
550
|
+
: 'audio/mpeg',
|
|
551
|
+
durationMs:
|
|
552
|
+
typeof data.durationMs === 'number' && data.durationMs > 0
|
|
553
|
+
? data.durationMs
|
|
554
|
+
: estimateAudioDurationMs(audioBase64),
|
|
555
|
+
} satisfies SpeakSuccess),
|
|
556
|
+
);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (payload.eventType === 'voice.speak.error') {
|
|
561
|
+
pending.resolve({
|
|
562
|
+
ok: false,
|
|
563
|
+
error: { kind: 'runtime_not_wired' },
|
|
564
|
+
});
|
|
565
|
+
}
|
|
378
566
|
};
|
|
379
567
|
|
|
380
568
|
const maybeHandleConsent = (payload: EventPayload): void => {
|
|
@@ -409,18 +597,40 @@ export const createSession = (config: SessionConfig): Session => {
|
|
|
409
597
|
})();
|
|
410
598
|
};
|
|
411
599
|
|
|
600
|
+
// `useSyncExternalStore` compares snapshots with `Object.is`. A fresh
|
|
601
|
+
// object on every `getState()` call makes React think the store
|
|
602
|
+
// changed on every read → infinite re-renders in shell hooks.
|
|
603
|
+
let cachedStoreSnapshot: ReturnType<SessionStore['getSnapshot']> | null =
|
|
604
|
+
null;
|
|
605
|
+
let cachedSessionState: SessionState | null = null;
|
|
606
|
+
|
|
607
|
+
const readSessionState = (): SessionState => {
|
|
608
|
+
const snap = store.getSnapshot();
|
|
609
|
+
if (cachedStoreSnapshot === snap && cachedSessionState !== null) {
|
|
610
|
+
return cachedSessionState;
|
|
611
|
+
}
|
|
612
|
+
cachedStoreSnapshot = snap;
|
|
613
|
+
cachedSessionState = {
|
|
614
|
+
connectionState: snap.connectionState,
|
|
615
|
+
state: snap.state,
|
|
616
|
+
projectedState: store.getProjectedState(),
|
|
617
|
+
appliedSeq: snap.appliedSeq,
|
|
618
|
+
phaseId: snap.phaseId,
|
|
619
|
+
sessionPaused: snap.sessionPaused,
|
|
620
|
+
pauseReason: snap.pauseReason,
|
|
621
|
+
recoveryCode: snap.recoveryCode,
|
|
622
|
+
recoveryCodeHint: snap.recoveryCodeHint,
|
|
623
|
+
isPreview: isPreviewFlag,
|
|
624
|
+
};
|
|
625
|
+
return cachedSessionState;
|
|
626
|
+
};
|
|
627
|
+
|
|
412
628
|
const session: Session = {
|
|
413
629
|
sessionId: config.sessionId,
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
return {
|
|
417
|
-
connectionState: snap.connectionState,
|
|
418
|
-
state: snap.state,
|
|
419
|
-
projectedState: store.getProjectedState(),
|
|
420
|
-
appliedSeq: snap.appliedSeq,
|
|
421
|
-
phaseId: snap.phaseId,
|
|
422
|
-
};
|
|
630
|
+
get isPreview(): boolean {
|
|
631
|
+
return isPreviewFlag;
|
|
423
632
|
},
|
|
633
|
+
getState: readSessionState,
|
|
424
634
|
subscribe: (listener) => store.subscribe(listener),
|
|
425
635
|
submit: (args) => {
|
|
426
636
|
const payload = buildSubmitPayload(config.sessionId, args);
|
|
@@ -515,7 +725,13 @@ export const createSession = (config: SessionConfig): Session => {
|
|
|
515
725
|
transport,
|
|
516
726
|
pendingInference,
|
|
517
727
|
),
|
|
518
|
-
voice: buildVoiceVerbs(
|
|
728
|
+
voice: buildVoiceVerbs(
|
|
729
|
+
config.sessionId,
|
|
730
|
+
transport,
|
|
731
|
+
pendingVoice,
|
|
732
|
+
scheduleTimer,
|
|
733
|
+
voiceSpeakTimeoutMs,
|
|
734
|
+
),
|
|
519
735
|
events: { onEvent: bus.onEvent, onAnyEvent: bus.onAnyEvent },
|
|
520
736
|
lifecycle,
|
|
521
737
|
time: { serverNow: time.serverNow, recordEvent: time.recordEvent },
|
|
@@ -537,8 +753,9 @@ export const createSession = (config: SessionConfig): Session => {
|
|
|
537
753
|
cb({ ok: false, error: { kind: 'not_connected' } });
|
|
538
754
|
}
|
|
539
755
|
pendingInference.clear();
|
|
540
|
-
for (const [,
|
|
541
|
-
|
|
756
|
+
for (const [, pending] of pendingVoice) {
|
|
757
|
+
pending.cancelTimeout();
|
|
758
|
+
pending.resolve({ ok: false, error: { kind: 'not_connected' } });
|
|
542
759
|
}
|
|
543
760
|
pendingVoice.clear();
|
|
544
761
|
bus.dispose();
|
|
@@ -607,8 +824,15 @@ const buildInferenceVerbs = (
|
|
|
607
824
|
};
|
|
608
825
|
return {
|
|
609
826
|
call,
|
|
827
|
+
// Convenience wrappers map to the most-common manifest `CallKind`s
|
|
828
|
+
// (per `@platform/manifest`'s `CallKindSchema` and
|
|
829
|
+
// `docs/conventions/prompt-composition.md`). Bundles that need a
|
|
830
|
+
// less-common kind (`host_resolve`, `host_recap`, `judge_funniness`,
|
|
831
|
+
// `compose_clue`, `narrate_event`) call `inference.call({ callKind,
|
|
832
|
+
// ... })` directly — the wrappers are a comfort for the dominant
|
|
833
|
+
// open-phase + judge paths, not an exhaustive cover.
|
|
610
834
|
host: (input) => call({ ...input, callKind: 'host_open_phase' }),
|
|
611
|
-
judge: (input) => call({ ...input, callKind: '
|
|
835
|
+
judge: (input) => call({ ...input, callKind: 'host_judge' }),
|
|
612
836
|
classify: (input) => call({ ...input, callKind: 'classify' }),
|
|
613
837
|
};
|
|
614
838
|
};
|
|
@@ -616,13 +840,17 @@ const buildInferenceVerbs = (
|
|
|
616
840
|
const buildVoiceVerbs = (
|
|
617
841
|
sessionId: SessionId,
|
|
618
842
|
transport: Transport,
|
|
619
|
-
pending: Map<MessageId,
|
|
843
|
+
pending: Map<MessageId, PendingVoiceRecord>,
|
|
844
|
+
schedule: (cb: () => void, ms: number) => () => void,
|
|
845
|
+
timeoutMs: number,
|
|
620
846
|
): SessionVoice => ({
|
|
621
847
|
speak: async (input) => {
|
|
622
848
|
let resolveFn!: (r: Result<unknown, SdkError>) => void;
|
|
623
849
|
const promise = new Promise<Result<unknown, SdkError>>((res) => {
|
|
624
850
|
resolveFn = res;
|
|
625
851
|
});
|
|
852
|
+
const caption =
|
|
853
|
+
input.caption === null ? null : (input.caption ?? input.text);
|
|
626
854
|
const sent = transport.send('emit', {
|
|
627
855
|
sessionId,
|
|
628
856
|
eventType: `${VOICE_EVENT_PREFIX}speak`,
|
|
@@ -630,17 +858,21 @@ const buildVoiceVerbs = (
|
|
|
630
858
|
personaId: input.personaId,
|
|
631
859
|
text: input.text,
|
|
632
860
|
options: input.options ?? {},
|
|
633
|
-
caption
|
|
861
|
+
caption,
|
|
634
862
|
},
|
|
635
863
|
});
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
if (cb === undefined) return;
|
|
864
|
+
const cancelTimeout = schedule(() => {
|
|
865
|
+
const record = pending.get(sent.id);
|
|
866
|
+
if (record === undefined) return;
|
|
640
867
|
pending.delete(sent.id);
|
|
641
868
|
transport.confirmSend(sent.id);
|
|
642
|
-
|
|
643
|
-
},
|
|
869
|
+
record.resolve({ ok: false, error: { kind: 'runtime_not_wired' } });
|
|
870
|
+
}, timeoutMs);
|
|
871
|
+
pending.set(sent.id, {
|
|
872
|
+
resolve: resolveFn,
|
|
873
|
+
caption,
|
|
874
|
+
cancelTimeout,
|
|
875
|
+
});
|
|
644
876
|
const result = await promise;
|
|
645
877
|
return result as Result<SpeakSuccess, SdkError>;
|
|
646
878
|
},
|
package/src/consent.ts
CHANGED
|
@@ -18,12 +18,12 @@
|
|
|
18
18
|
*
|
|
19
19
|
* **Persistence path.** When the player accepts, the shell calls
|
|
20
20
|
* `session.requestConsent(...)` which forwards to the User Portal's
|
|
21
|
-
* consent endpoint (chunk
|
|
21
|
+
* consent endpoint (chunk B19a). Until B19a lands the endpoint, the
|
|
22
22
|
* verb returns `Result.err({ kind: 'consent_persistence_not_wired' })`
|
|
23
23
|
* — the prompt callback still fires; only the grant-write is deferred.
|
|
24
24
|
*
|
|
25
25
|
* **Trap discipline** (chunk B7): the consent management surface lives
|
|
26
|
-
* in `apps/portal` (
|
|
26
|
+
* in `apps/portal` (B19a), not here. The SDK exposes the mid-Session
|
|
27
27
|
* prompt verbs only — `requestConsent` (grant a fresh consent),
|
|
28
28
|
* surfaces via `useEvent('consent_required')`. Revoke / list lives on
|
|
29
29
|
* the User Portal Settings page.
|
|
@@ -34,6 +34,21 @@
|
|
|
34
34
|
* change here is a PROTOCOL_VERSION bump.
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Per-Persona override copy for the mid-Session consent dialog
|
|
39
|
+
* (chunk B15). Either sub-field may be omitted to fall back to the
|
|
40
|
+
* platform default copy in the Player Web Shell's
|
|
41
|
+
* `apps-shells/player-web/src/lib/player-meta.ts` (`CONSENT_PROMPT_COPY`).
|
|
42
|
+
*
|
|
43
|
+
* Sourced from `personas.consent_copy` (chunk B15 schema addition);
|
|
44
|
+
* the Runtime's persona-forwarder enriches the wire `consent_required`
|
|
45
|
+
* event payload with this field when the Persona has it set.
|
|
46
|
+
*/
|
|
47
|
+
export type ConsentCopyOverride = {
|
|
48
|
+
readonly title?: string;
|
|
49
|
+
readonly description?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
37
52
|
/**
|
|
38
53
|
* Payload the SDK surfaces on a `consent_required` callback. Mirrors
|
|
39
54
|
* the shape the Persona Service emits (chunk B3) when `readMemoryEntry`
|
|
@@ -44,11 +59,18 @@
|
|
|
44
59
|
* prompt copy.
|
|
45
60
|
* - `scope` — `session | group | player` per the
|
|
46
61
|
* `player_consent_scope` enum.
|
|
62
|
+
* - `consentCopy` — chunk B15 per-Persona override copy.
|
|
63
|
+
* Optional; falls back to the
|
|
64
|
+
* platform-default copy when absent. The
|
|
65
|
+
* field itself is optional (back-compatible
|
|
66
|
+
* addition); a missing-or-undefined value
|
|
67
|
+
* behaves the same way.
|
|
47
68
|
*/
|
|
48
69
|
export type ConsentRequiredPayload = {
|
|
49
70
|
readonly personaId: string;
|
|
50
71
|
readonly personaDisplayName: string;
|
|
51
72
|
readonly scope: 'session' | 'group' | 'player';
|
|
73
|
+
readonly consentCopy?: ConsentCopyOverride;
|
|
52
74
|
};
|
|
53
75
|
|
|
54
76
|
/**
|
|
@@ -62,7 +84,7 @@ export type ConsentDecision = 'accepted' | 'declined';
|
|
|
62
84
|
/**
|
|
63
85
|
* The SDK callback signature the Player shell binds against. The
|
|
64
86
|
* component resolves the returned promise; the SDK forwards the
|
|
65
|
-
* decision to the persistence path (
|
|
87
|
+
* decision to the persistence path (B19a) on accept.
|
|
66
88
|
*/
|
|
67
89
|
export type ConsentRequiredCallback = (
|
|
68
90
|
payload: ConsentRequiredPayload,
|
package/src/control.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* - `host.pause` — request a pause; the server emits a
|
|
14
14
|
* `lifecycle` `session.paused`.
|
|
15
15
|
* - `host.resume` — resume from pause.
|
|
16
|
-
* - `host.
|
|
16
|
+
* - `host.advancePhase` — request the next phase. The Runtime
|
|
17
17
|
* may reject if no transition matches.
|
|
18
18
|
* - `host.reclaim` — reclaim the host slot from a hung
|
|
19
19
|
* host (the player who fires this
|
|
@@ -25,7 +25,7 @@ import type { EmitPayload } from '@wibly/internal-protocol';
|
|
|
25
25
|
export const HOST_EVENT_TYPES = {
|
|
26
26
|
pause: 'host.pause',
|
|
27
27
|
resume: 'host.resume',
|
|
28
|
-
advancePhase: 'host.
|
|
28
|
+
advancePhase: 'host.advancePhase',
|
|
29
29
|
reclaim: 'host.reclaim',
|
|
30
30
|
} as const;
|
|
31
31
|
|
package/src/errors.ts
CHANGED
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
* player shell renders the dialog.
|
|
23
23
|
* - `consent_persistence_not_wired` — the SDK forwards a granted
|
|
24
24
|
* consent to the User Portal endpoint
|
|
25
|
-
* (chunk
|
|
26
|
-
*
|
|
25
|
+
* (chunk B19a); MVP returns this until
|
|
26
|
+
* B19a ships the route. The mid-Session
|
|
27
27
|
* callback still fires so the shell
|
|
28
28
|
* renders the prompt; this error is
|
|
29
29
|
* returned from `session.requestConsent`
|
|
@@ -79,7 +79,7 @@ export const formatSdkError = (e: SdkError): string => {
|
|
|
79
79
|
case 'consent_required':
|
|
80
80
|
return 'sdk: consent required';
|
|
81
81
|
case 'consent_persistence_not_wired':
|
|
82
|
-
return 'sdk: consent persistence endpoint is not yet wired (chunk
|
|
82
|
+
return 'sdk: consent persistence endpoint is not yet wired (chunk B19a)';
|
|
83
83
|
case 'runtime_not_wired':
|
|
84
84
|
return 'sdk: runtime endpoint is not yet wired (chunk B8a)';
|
|
85
85
|
case 'timeout':
|
package/src/index.ts
CHANGED
package/src/inference.ts
CHANGED
|
@@ -36,34 +36,42 @@ import type { z, ZodTypeAny } from 'zod';
|
|
|
36
36
|
|
|
37
37
|
import { zodToJsonSchema, type JsonSchema } from '@wibly/internal-shared';
|
|
38
38
|
import type { Result } from '@wibly/internal-shared';
|
|
39
|
+
import type { CallKind, QualityTier } from '@wibly/internal-manifest';
|
|
39
40
|
|
|
40
41
|
import type { SdkError } from './errors.js';
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
|
-
* Quality tier surfaced on the SDK boundary.
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
44
|
+
* Quality tier surfaced on the SDK boundary. Type-only re-export of
|
|
45
|
+
* `@platform/manifest`'s `QualityTier` so the manifest stays the
|
|
46
|
+
* single source of truth for the on-the-wire enum. The `import type`
|
|
47
|
+
* is erased at compile time, so the SDK still does not pull in the
|
|
48
|
+
* manifest's Zod runtime — only the type literals.
|
|
49
|
+
*
|
|
50
|
+
* The B7 close note ("kept as a string literal here so the SDK
|
|
51
|
+
* doesn't pull in the manifest's full Zod runtime") was right about
|
|
52
|
+
* the runtime concern but wrong to duplicate the literals — the
|
|
53
|
+
* duplication had already drifted from the manifest's enum at chunk
|
|
54
|
+
* close. Type-only re-export gives the same runtime weight (zero)
|
|
55
|
+
* with the contract honoured at compile time.
|
|
48
56
|
*/
|
|
49
|
-
export type SdkQualityTier =
|
|
57
|
+
export type SdkQualityTier = QualityTier;
|
|
50
58
|
|
|
51
59
|
/**
|
|
52
|
-
* Recognised call kinds.
|
|
53
|
-
*
|
|
60
|
+
* Recognised call kinds. Type-only re-export of `@platform/manifest`'s
|
|
61
|
+
* `CallKind`; the manifest's `CallKindSchema` (enumerating
|
|
62
|
+
* `host_open_phase`, `host_judge`, `host_resolve`, `host_recap`,
|
|
63
|
+
* `judge_funniness`, `narrate_event`, `classify`, `compose_clue`) is
|
|
64
|
+
* the canonical wire-side set. New `callKind`s land in
|
|
65
|
+
* `@platform/manifest` and `docs/conventions/prompt-composition.md`
|
|
66
|
+
* in the same commit per chunk B4's convention; the SDK's enum
|
|
67
|
+
* follows automatically because it's a type alias.
|
|
54
68
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
69
|
+
* The trailing `(string & {})` widening preserves the chunk-B7
|
|
70
|
+
* authoring-aid behaviour ("the SDK accepts any string at runtime")
|
|
71
|
+
* while keeping the literal set as IntelliSense-discoverable
|
|
72
|
+
* autocompletes for in-tree game bundles.
|
|
59
73
|
*/
|
|
60
|
-
export type SdkCallKind =
|
|
61
|
-
| 'host_open_phase'
|
|
62
|
-
| 'narrate_event'
|
|
63
|
-
| 'judge_submissions'
|
|
64
|
-
| 'host_persona_response'
|
|
65
|
-
| 'classify'
|
|
66
|
-
| (string & {});
|
|
74
|
+
export type SdkCallKind = CallKind | (string & {});
|
|
67
75
|
|
|
68
76
|
export type InferenceCallInput<TOutput extends ZodTypeAny> = {
|
|
69
77
|
readonly callKind: SdkCallKind;
|
package/src/lifecycle.ts
CHANGED
|
@@ -47,7 +47,16 @@ const PHASE_EXITED_PREFIX = 'phase.exited';
|
|
|
47
47
|
|
|
48
48
|
export const createLifecycleBindings = (bus: EventBus): LifecycleBindings => ({
|
|
49
49
|
onSessionOpened: (handler) => bus.onLifecycle('session.opened', handler),
|
|
50
|
-
onSessionClosed: (handler) =>
|
|
50
|
+
onSessionClosed: (handler) => {
|
|
51
|
+
const offClosed = bus.onLifecycle('session.closed', handler);
|
|
52
|
+
const offAborted = bus.onLifecycle('session.aborted', handler);
|
|
53
|
+
const offLegacy = bus.onLifecycle('aborted', handler);
|
|
54
|
+
return () => {
|
|
55
|
+
offClosed();
|
|
56
|
+
offAborted();
|
|
57
|
+
offLegacy();
|
|
58
|
+
};
|
|
59
|
+
},
|
|
51
60
|
onPhaseEntered: (handler) => {
|
|
52
61
|
return bus.onAnyLifecycle((payload) => {
|
|
53
62
|
if (
|
|
@@ -55,6 +64,18 @@ export const createLifecycleBindings = (bus: EventBus): LifecycleBindings => ({
|
|
|
55
64
|
payload.transition.startsWith(`${PHASE_ENTERED_PREFIX}:`)
|
|
56
65
|
) {
|
|
57
66
|
handler(payload);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (payload.transition === 'phase.changed') {
|
|
70
|
+
const detail = payload.detail;
|
|
71
|
+
if (detail === null || typeof detail !== 'object') return;
|
|
72
|
+
const toPhaseId = (detail as { toPhaseId?: unknown }).toPhaseId;
|
|
73
|
+
if (typeof toPhaseId !== 'string') return;
|
|
74
|
+
handler({
|
|
75
|
+
...payload,
|
|
76
|
+
transition: `${PHASE_ENTERED_PREFIX}:${toPhaseId}`,
|
|
77
|
+
detail: { ...detail, phaseId: toPhaseId },
|
|
78
|
+
});
|
|
58
79
|
}
|
|
59
80
|
});
|
|
60
81
|
},
|
package/src/react.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
useContext,
|
|
27
27
|
useEffect,
|
|
28
28
|
useMemo,
|
|
29
|
+
useRef,
|
|
29
30
|
useState,
|
|
30
31
|
useSyncExternalStore,
|
|
31
32
|
type ReactNode,
|
|
@@ -34,7 +35,8 @@ import {
|
|
|
34
35
|
import type { EventPayload, LifecyclePayload } from '@wibly/internal-protocol';
|
|
35
36
|
|
|
36
37
|
import type { ConsentDecision, ConsentRequiredPayload } from './consent.js';
|
|
37
|
-
import type { Session, SessionState } from './client.js';
|
|
38
|
+
import type { Session, SessionConfig, SessionState } from './client.js';
|
|
39
|
+
import { createSession } from './client.js';
|
|
38
40
|
|
|
39
41
|
const SessionContext = createContext<Session | null>(null);
|
|
40
42
|
|
|
@@ -55,6 +57,35 @@ export const SessionProvider = (props: SessionProviderProps): ReactNode =>
|
|
|
55
57
|
props.children,
|
|
56
58
|
);
|
|
57
59
|
|
|
60
|
+
const clientSessionKey = (config: SessionConfig): string =>
|
|
61
|
+
`${config.sessionId}\0${config.wsUrl}\0${config.auth ?? ''}`;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Create a {@link Session} only in the browser after mount.
|
|
65
|
+
*
|
|
66
|
+
* Shell surfaces must not call `createSession` during SSR — Node can
|
|
67
|
+
* open a WebSocket that is orphaned on hydration, and React Strict
|
|
68
|
+
* Mode's mount/unmount/remount cycle permanently closes a session
|
|
69
|
+
* created in `useMemo`. Returns `null` until the client effect runs.
|
|
70
|
+
*/
|
|
71
|
+
export const useClientSession = (config: SessionConfig): Session | null => {
|
|
72
|
+
const configRef = useRef(config);
|
|
73
|
+
configRef.current = config;
|
|
74
|
+
const [session, setSession] = useState<Session | null>(null);
|
|
75
|
+
const key = clientSessionKey(config);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const live = createSession(configRef.current);
|
|
79
|
+
setSession(live);
|
|
80
|
+
return () => {
|
|
81
|
+
live.close();
|
|
82
|
+
setSession(null);
|
|
83
|
+
};
|
|
84
|
+
}, [key]);
|
|
85
|
+
|
|
86
|
+
return session;
|
|
87
|
+
};
|
|
88
|
+
|
|
58
89
|
/**
|
|
59
90
|
* Access the live `Session`. Throws if no provider is mounted (a
|
|
60
91
|
* deliberate fail-fast — the alternative is a silent null that
|
package/src/store.ts
CHANGED
|
@@ -43,6 +43,12 @@ export type SessionStoreSnapshot = {
|
|
|
43
43
|
readonly state: unknown;
|
|
44
44
|
readonly phaseId: string | null;
|
|
45
45
|
readonly appliedSeq: number;
|
|
46
|
+
/** Set when the Runtime broadcasts `lifecycle.paused`. */
|
|
47
|
+
readonly sessionPaused: boolean;
|
|
48
|
+
readonly pauseReason: 'host' | 'system' | null;
|
|
49
|
+
/** Active orphan-seat recovery code for the host (chunk B8a). */
|
|
50
|
+
readonly recoveryCode: string | null;
|
|
51
|
+
readonly recoveryCodeHint: string | null;
|
|
46
52
|
/**
|
|
47
53
|
* Identifiers of the optimistic projections currently applied on
|
|
48
54
|
* top of `state`. The actual projection functions are stored
|
|
@@ -61,6 +67,15 @@ export type SessionStore = {
|
|
|
61
67
|
/** Internal mutators — not exposed past `createSession`. */
|
|
62
68
|
readonly setConnectionState: (next: ConnectionState) => void;
|
|
63
69
|
readonly applySnapshot: (state: unknown, phaseId: string, baseSeq: number) => void;
|
|
70
|
+
readonly setPhaseId: (phaseId: string | null) => void;
|
|
71
|
+
readonly setSessionPaused: (
|
|
72
|
+
paused: boolean,
|
|
73
|
+
reason?: 'host' | 'system' | null,
|
|
74
|
+
) => void;
|
|
75
|
+
readonly setRecoveryCode: (
|
|
76
|
+
code: string | null,
|
|
77
|
+
hint?: string | null,
|
|
78
|
+
) => void;
|
|
64
79
|
readonly applyDiff: (
|
|
65
80
|
patches: readonly JsonPatchOp[],
|
|
66
81
|
fromSeq: number,
|
|
@@ -85,6 +100,10 @@ export const createSessionStore = (): SessionStore => {
|
|
|
85
100
|
state: null,
|
|
86
101
|
phaseId: null,
|
|
87
102
|
appliedSeq: 0,
|
|
103
|
+
sessionPaused: false,
|
|
104
|
+
pauseReason: null,
|
|
105
|
+
recoveryCode: null,
|
|
106
|
+
recoveryCodeHint: null,
|
|
88
107
|
pendingProjectionIds: [],
|
|
89
108
|
};
|
|
90
109
|
|
|
@@ -123,6 +142,31 @@ export const createSessionStore = (): SessionStore => {
|
|
|
123
142
|
applySnapshot: (state, phaseId, baseSeq) => {
|
|
124
143
|
refreshSnapshot({ state, phaseId, appliedSeq: baseSeq });
|
|
125
144
|
},
|
|
145
|
+
setPhaseId: (phaseId) => {
|
|
146
|
+
if (phaseId === snapshot.phaseId) return;
|
|
147
|
+
refreshSnapshot({ phaseId });
|
|
148
|
+
},
|
|
149
|
+
setSessionPaused: (paused, reason = null) => {
|
|
150
|
+
if (paused === snapshot.sessionPaused && reason === snapshot.pauseReason) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
refreshSnapshot({
|
|
154
|
+
sessionPaused: paused,
|
|
155
|
+
pauseReason: paused ? reason : null,
|
|
156
|
+
});
|
|
157
|
+
},
|
|
158
|
+
setRecoveryCode: (code, hint = null) => {
|
|
159
|
+
if (
|
|
160
|
+
code === snapshot.recoveryCode &&
|
|
161
|
+
hint === snapshot.recoveryCodeHint
|
|
162
|
+
) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
refreshSnapshot({
|
|
166
|
+
recoveryCode: code,
|
|
167
|
+
recoveryCodeHint: hint,
|
|
168
|
+
});
|
|
169
|
+
},
|
|
126
170
|
applyDiff: (patches, fromSeq, toSeq) => {
|
|
127
171
|
if (fromSeq !== snapshot.appliedSeq) {
|
|
128
172
|
return 'resync_required';
|
package/src/voice.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import type { Result } from '@wibly/internal-shared';
|
|
24
24
|
|
|
25
25
|
import type { SdkError } from './errors.js';
|
|
26
|
+
import type { SdkQualityTier } from './inference.js';
|
|
26
27
|
|
|
27
28
|
export type SpeakInput = {
|
|
28
29
|
readonly personaId: string;
|
|
@@ -30,7 +31,7 @@ export type SpeakInput = {
|
|
|
30
31
|
readonly options?: {
|
|
31
32
|
readonly voiceId?: string;
|
|
32
33
|
readonly modelId?: string;
|
|
33
|
-
readonly qualityTier?:
|
|
34
|
+
readonly qualityTier?: SdkQualityTier;
|
|
34
35
|
};
|
|
35
36
|
/**
|
|
36
37
|
* Caption text rendered through the SDK's `voice.caption` event.
|