@talqing/sdk 0.1.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.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +193 -0
  3. package/dist/browser.d.ts +153 -0
  4. package/dist/browser.d.ts.map +1 -0
  5. package/dist/browser.js +440 -0
  6. package/dist/browser.js.map +1 -0
  7. package/dist/client.d.ts +101 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +88 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/gen/apiVersion.d.ts +3 -0
  12. package/dist/gen/apiVersion.d.ts.map +1 -0
  13. package/dist/gen/apiVersion.js +4 -0
  14. package/dist/gen/apiVersion.js.map +1 -0
  15. package/dist/gen/client/client.gen.d.ts +3 -0
  16. package/dist/gen/client/client.gen.d.ts.map +1 -0
  17. package/dist/gen/client/client.gen.js +217 -0
  18. package/dist/gen/client/client.gen.js.map +1 -0
  19. package/dist/gen/client/index.d.ts +11 -0
  20. package/dist/gen/client/index.d.ts.map +1 -0
  21. package/dist/gen/client/index.js +7 -0
  22. package/dist/gen/client/index.js.map +1 -0
  23. package/dist/gen/client/types.gen.d.ts +121 -0
  24. package/dist/gen/client/types.gen.d.ts.map +1 -0
  25. package/dist/gen/client/types.gen.js +3 -0
  26. package/dist/gen/client/types.gen.js.map +1 -0
  27. package/dist/gen/client/utils.gen.d.ts +38 -0
  28. package/dist/gen/client/utils.gen.d.ts.map +1 -0
  29. package/dist/gen/client/utils.gen.js +231 -0
  30. package/dist/gen/client/utils.gen.js.map +1 -0
  31. package/dist/gen/client.gen.d.ts +13 -0
  32. package/dist/gen/client.gen.d.ts.map +1 -0
  33. package/dist/gen/client.gen.js +4 -0
  34. package/dist/gen/client.gen.js.map +1 -0
  35. package/dist/gen/core/auth.gen.d.ts +26 -0
  36. package/dist/gen/core/auth.gen.d.ts.map +1 -0
  37. package/dist/gen/core/auth.gen.js +15 -0
  38. package/dist/gen/core/auth.gen.js.map +1 -0
  39. package/dist/gen/core/bodySerializer.gen.d.ts +26 -0
  40. package/dist/gen/core/bodySerializer.gen.d.ts.map +1 -0
  41. package/dist/gen/core/bodySerializer.gen.js +58 -0
  42. package/dist/gen/core/bodySerializer.gen.js.map +1 -0
  43. package/dist/gen/core/params.gen.d.ts +44 -0
  44. package/dist/gen/core/params.gen.d.ts.map +1 -0
  45. package/dist/gen/core/params.gen.js +110 -0
  46. package/dist/gen/core/params.gen.js.map +1 -0
  47. package/dist/gen/core/pathSerializer.gen.d.ts +34 -0
  48. package/dist/gen/core/pathSerializer.gen.d.ts.map +1 -0
  49. package/dist/gen/core/pathSerializer.gen.js +107 -0
  50. package/dist/gen/core/pathSerializer.gen.js.map +1 -0
  51. package/dist/gen/core/queryKeySerializer.gen.d.ts +19 -0
  52. package/dist/gen/core/queryKeySerializer.gen.d.ts.map +1 -0
  53. package/dist/gen/core/queryKeySerializer.gen.js +93 -0
  54. package/dist/gen/core/queryKeySerializer.gen.js.map +1 -0
  55. package/dist/gen/core/serverSentEvents.gen.d.ts +72 -0
  56. package/dist/gen/core/serverSentEvents.gen.d.ts.map +1 -0
  57. package/dist/gen/core/serverSentEvents.gen.js +133 -0
  58. package/dist/gen/core/serverSentEvents.gen.js.map +1 -0
  59. package/dist/gen/core/types.gen.d.ts +84 -0
  60. package/dist/gen/core/types.gen.d.ts.map +1 -0
  61. package/dist/gen/core/types.gen.js +3 -0
  62. package/dist/gen/core/types.gen.js.map +1 -0
  63. package/dist/gen/core/utils.gen.d.ts +20 -0
  64. package/dist/gen/core/utils.gen.d.ts.map +1 -0
  65. package/dist/gen/core/utils.gen.js +88 -0
  66. package/dist/gen/core/utils.gen.js.map +1 -0
  67. package/dist/gen/index.d.ts +3 -0
  68. package/dist/gen/index.d.ts.map +1 -0
  69. package/dist/gen/index.js +3 -0
  70. package/dist/gen/index.js.map +1 -0
  71. package/dist/gen/sdk.gen.d.ts +2330 -0
  72. package/dist/gen/sdk.gen.d.ts.map +1 -0
  73. package/dist/gen/sdk.gen.js +4320 -0
  74. package/dist/gen/sdk.gen.js.map +1 -0
  75. package/dist/gen/types.gen.d.ts +17288 -0
  76. package/dist/gen/types.gen.d.ts.map +1 -0
  77. package/dist/gen/types.gen.js +3 -0
  78. package/dist/gen/types.gen.js.map +1 -0
  79. package/dist/index.d.ts +6 -0
  80. package/dist/index.d.ts.map +1 -0
  81. package/dist/index.js +6 -0
  82. package/dist/index.js.map +1 -0
  83. package/dist/vocabulary.d.ts +67 -0
  84. package/dist/vocabulary.d.ts.map +1 -0
  85. package/dist/vocabulary.js +12 -0
  86. package/dist/vocabulary.js.map +1 -0
  87. package/package.json +67 -0
  88. package/src/browser.tsx +675 -0
  89. package/src/client.ts +189 -0
  90. package/src/gen/apiVersion.ts +4 -0
  91. package/src/gen/client/client.gen.ts +277 -0
  92. package/src/gen/client/index.ts +27 -0
  93. package/src/gen/client/types.gen.ts +218 -0
  94. package/src/gen/client/utils.gen.ts +316 -0
  95. package/src/gen/client.gen.ts +16 -0
  96. package/src/gen/core/auth.gen.ts +48 -0
  97. package/src/gen/core/bodySerializer.gen.ts +82 -0
  98. package/src/gen/core/params.gen.ts +178 -0
  99. package/src/gen/core/pathSerializer.gen.ts +171 -0
  100. package/src/gen/core/queryKeySerializer.gen.ts +117 -0
  101. package/src/gen/core/serverSentEvents.gen.ts +242 -0
  102. package/src/gen/core/types.gen.ts +110 -0
  103. package/src/gen/core/utils.gen.ts +140 -0
  104. package/src/gen/index.ts +4 -0
  105. package/src/gen/sdk.gen.ts +5008 -0
  106. package/src/gen/types.gen.ts +18418 -0
  107. package/src/index.ts +13 -0
  108. package/src/vocabulary.ts +134 -0
@@ -0,0 +1,675 @@
1
+ "use client";
2
+
3
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import {
5
+ ParticipantKind,
6
+ Room,
7
+ RpcError,
8
+ TokenSource,
9
+ Track,
10
+ setLogLevel,
11
+ LogLevel,
12
+ type RoomOptions,
13
+ type RemoteParticipant,
14
+ type RpcInvocationData,
15
+ } from "livekit-client";
16
+ import {
17
+ RoomAudioRenderer,
18
+ SessionProvider,
19
+ VideoTrack,
20
+ useAgent,
21
+ useParticipantTracks,
22
+ useRemoteParticipants,
23
+ useSession,
24
+ useSessionContext,
25
+ useSessionMessages,
26
+ useTrackToggle,
27
+ useVoiceAssistant,
28
+ type ReceivedMessage,
29
+ type TrackReference,
30
+ type UseSessionReturn,
31
+ } from "@livekit/components-react";
32
+ import type { JsonObject, JsonValue, TalqingClient } from "./client.js";
33
+ import type {
34
+ ScreenShareCaptureOptions,
35
+ TrackPublishOptions,
36
+ } from "livekit-client";
37
+ import type { CallTokenResponse, TokenRequest } from "./gen/types.gen.js";
38
+
39
+ /** Everything about one web call except which agent runs it. Wire-shaped, like
40
+ * every other request type in this SDK. */
41
+ export type TalqingCallOptions = Omit<TokenRequest, "agent_id">;
42
+
43
+ export interface TalqingBrowserSessionOptions {
44
+ client: TalqingClient;
45
+ agentId: string | null;
46
+ roomOptions?: RoomOptions;
47
+ callOptions?: TalqingCallOptions;
48
+ onTokenDetails?: (details: CallTokenResponse) => void;
49
+ }
50
+
51
+ export interface TalqingRpcInvocation {
52
+ payload: string;
53
+ responseTimeout?: number;
54
+ callerIdentity?: string;
55
+ }
56
+
57
+ export type TalqingRpcHandler = (
58
+ data: TalqingRpcInvocation,
59
+ ) => string | Promise<string>;
60
+ export type TalqingRpcHandlers = Record<string, TalqingRpcHandler>;
61
+
62
+ export interface TalqingFrontendRpcEnvelope {
63
+ method: string;
64
+ payload?: JsonValue;
65
+ }
66
+
67
+ export class TalqingRpcError extends Error {
68
+ readonly code: number;
69
+ readonly data?: string;
70
+
71
+ constructor(code: number, message: string, data?: string) {
72
+ super(message);
73
+ this.name = "TalqingRpcError";
74
+ this.code = code;
75
+ this.data = data;
76
+ }
77
+ }
78
+
79
+ export type TalqingSession = UseSessionReturn;
80
+ export type TalqingMessage = ReceivedMessage;
81
+ export type TalqingVideoTrackRef = TrackReference;
82
+
83
+ export function createTalqingTokenSource(
84
+ client: TalqingClient,
85
+ agentId: string | null,
86
+ options: TalqingCallOptions = {},
87
+ onTokenDetails?: (details: CallTokenResponse) => void,
88
+ ) {
89
+ // Carry the contact across reconnects. POST /v1/calls/token mints a key when
90
+ // none is supplied, so re-running this without pinning it would file every
91
+ // reconnect as a different person.
92
+ let resolvedContactKey = options.contact_key ?? null;
93
+
94
+ // `useSession` fetches a token twice off one click — once to warm the
95
+ // connection, once to connect. Unserialised they race: both go out before
96
+ // either resolves, so neither carries a key, the API mints one per request,
97
+ // and a single call is filed against two different contacts. Concurrent
98
+ // callers share one request.
99
+ //
100
+ // This coalesces, it does not cache: the promise is dropped once it settles,
101
+ // so every later connect still mints a fresh token. That matters — a token
102
+ // bakes in {agent version, room}, so a reused one silently runs a stale
103
+ // version after a republish, and reconnects into a dead room.
104
+ let inFlight: Promise<{ serverUrl: string; participantToken: string }> | null = null;
105
+
106
+ return TokenSource.literal(async () => {
107
+ if (inFlight) return inFlight;
108
+ const request = (async () => {
109
+ const details = await client.calls.token({
110
+ tokenRequest: { ...options, agent_id: agentId, contact_key: resolvedContactKey },
111
+ });
112
+ resolvedContactKey = details.contact_key;
113
+ onTokenDetails?.(details);
114
+ return {
115
+ serverUrl: details.server_url,
116
+ participantToken: details.participant_token,
117
+ };
118
+ })();
119
+ inFlight = request;
120
+ try {
121
+ return await request;
122
+ } finally {
123
+ inFlight = null;
124
+ }
125
+ });
126
+ }
127
+
128
+ /* Echo cancellation is what stops the agent hearing its own speech through the
129
+ * caller's microphone and interrupting itself. It is a default rather than
130
+ * something each app remembers, because a call without it is broken in a way
131
+ * that sounds like a bad model. Pass `roomOptions.audioCaptureDefaults` to
132
+ * override. */
133
+ export const TALQING_AUDIO_CAPTURE_DEFAULTS = {
134
+ echoCancellation: true,
135
+ noiseSuppression: true,
136
+ autoGainControl: true,
137
+ } as const;
138
+
139
+ export function useTalqingSession({
140
+ client,
141
+ agentId,
142
+ roomOptions,
143
+ callOptions,
144
+ onTokenDetails,
145
+ }: TalqingBrowserSessionOptions): TalqingSession {
146
+ const tokenSource = useMemo(
147
+ () => createTalqingTokenSource(client, agentId, callOptions, onTokenDetails),
148
+ [client, agentId, callOptions, onTokenDetails],
149
+ );
150
+ const room = useMemo(
151
+ () =>
152
+ new Room({ audioCaptureDefaults: TALQING_AUDIO_CAPTURE_DEFAULTS, ...roomOptions }),
153
+ [roomOptions],
154
+ );
155
+ return useSession(tokenSource, { room });
156
+ }
157
+
158
+ export function TalqingSessionProvider({
159
+ session,
160
+ children,
161
+ audio = true,
162
+ }: {
163
+ session: TalqingSession;
164
+ children: React.ReactNode;
165
+ audio?: boolean;
166
+ }) {
167
+ return (
168
+ <SessionProvider session={session}>
169
+ {children}
170
+ {audio ? <RoomAudioRenderer /> : null}
171
+ </SessionProvider>
172
+ );
173
+ }
174
+
175
+ export function useTalqingConnection() {
176
+ const ctx = useSessionContext();
177
+ return {
178
+ isConnected: ctx.isConnected,
179
+ connectionState: ctx.connectionState,
180
+ room: ctx.room,
181
+ start: ctx.start,
182
+ end: ctx.end,
183
+ };
184
+ }
185
+
186
+ export async function setTalqingUserdata(
187
+ room: Room,
188
+ patch: JsonObject,
189
+ responseTimeout = 3,
190
+ ): Promise<{ ok: boolean; error?: string }> {
191
+ const agent = [...room.remoteParticipants.values()].find(
192
+ (participant: RemoteParticipant) => participant.kind === ParticipantKind.AGENT,
193
+ );
194
+ if (!agent) return { ok: false, error: "agent participant is not connected" };
195
+ const response = await room.localParticipant.performRpc({
196
+ destinationIdentity: agent.identity,
197
+ method: "talqing.client.userdata_set",
198
+ payload: JSON.stringify(patch),
199
+ responseTimeout,
200
+ });
201
+ return JSON.parse(response || "{}") as { ok: boolean; error?: string };
202
+ }
203
+
204
+ export async function getTalqingUserdata(
205
+ room: Room,
206
+ keys?: string[],
207
+ responseTimeout = 3,
208
+ ): Promise<JsonObject> {
209
+ const agent = [...room.remoteParticipants.values()].find(
210
+ (participant: RemoteParticipant) => participant.kind === ParticipantKind.AGENT,
211
+ );
212
+ if (!agent) throw new Error("agent participant is not connected");
213
+ const response = await room.localParticipant.performRpc({
214
+ destinationIdentity: agent.identity,
215
+ method: "talqing.client.userdata_get",
216
+ payload: JSON.stringify(keys ? { keys } : {}),
217
+ responseTimeout,
218
+ });
219
+ const parsed = JSON.parse(response || "{}") as JsonObject & { ok?: boolean; error?: string };
220
+ if (parsed.ok === false) {
221
+ throw new Error(String(parsed.error || "userdata RPC failed"));
222
+ }
223
+ return parsed;
224
+ }
225
+
226
+ export function useTalqingUserdata() {
227
+ const { room } = useTalqingConnection();
228
+ return useMemo(
229
+ () => ({
230
+ get: (keys?: string[], responseTimeout?: number) => {
231
+ if (!room) throw new Error("room is not connected");
232
+ return getTalqingUserdata(room, keys, responseTimeout);
233
+ },
234
+ set: (patch: JsonObject, responseTimeout?: number) => {
235
+ if (!room) throw new Error("room is not connected");
236
+ return setTalqingUserdata(room, patch, responseTimeout);
237
+ },
238
+ }),
239
+ [room],
240
+ );
241
+ }
242
+
243
+ /* ── images ───────────────────────────────────────────────────────────────
244
+ *
245
+ * A caller attaches a photo on a web voice or video call and the agent sees
246
+ * it. The bytes go over a LiveKit byte stream on the `talqing.images` topic,
247
+ * addressed to the agent; the agent answers on an RPC back, and the id
248
+ * `sendFile` returns is the id that ack carries, so there is nothing to
249
+ * correlate by hand.
250
+ *
251
+ * The agent deliberately does NOT speak when a photo lands — the caller is
252
+ * mid-conversation and about to say what it is for. The image is in the
253
+ * model's view for their next turn.
254
+ */
255
+
256
+ export const TALQING_IMAGE_TOPIC = "talqing.images";
257
+ /** Decoded size the server refuses above. The downscale below keeps a phone
258
+ * photo far under it; this is the belt for a file that is not a photo. */
259
+ export const TALQING_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
260
+ /** Longest side the server stores. Downscaling to it before sending is a
261
+ * courtesy to the data channel, which is ordered and reliable and will
262
+ * happily spend a minute pushing 10 MB past a call's audio. */
263
+ export const TALQING_IMAGE_MAX_EDGE_PX = 1568;
264
+ const TALQING_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"] as const;
265
+ /** How long to wait for the agent's ack before saying we do not know. Without
266
+ * it a thumbnail sits on "sending" for the rest of the call when the worker
267
+ * dies mid-upload. */
268
+ const TALQING_IMAGE_ACK_TIMEOUT_MS = 30_000;
269
+
270
+ export type TalqingImageStatus = "sending" | "sent" | "failed" | "unknown";
271
+
272
+ export interface TalqingSentImage {
273
+ /** The stream id `sendFile` returned, which the ack comes back under. */
274
+ streamId: string;
275
+ name: string;
276
+ /** An object URL for the downscaled image, for rendering the thumbnail.
277
+ * Revoked when the hook unmounts. */
278
+ previewUrl: string;
279
+ status: TalqingImageStatus;
280
+ /** Set when `status` is "failed" or "unknown" — a sentence, shown as-is. */
281
+ error?: string;
282
+ }
283
+
284
+ /** Refuse what the browser can see is wrong, instantly and with no round trip.
285
+ * Returns the sentence to show, or null when the file is fine. The server
286
+ * re-validates everything regardless — this exists so the common mistakes cost
287
+ * nothing to catch. */
288
+ export function talqingImageRejection(file: File): string | null {
289
+ if (!(TALQING_IMAGE_TYPES as readonly string[]).includes(file.type)) {
290
+ return "that file is not a JPEG, PNG or WebP";
291
+ }
292
+ if (file.size > TALQING_IMAGE_MAX_BYTES) return "images are 10 MB or smaller";
293
+ return null;
294
+ }
295
+
296
+ /** Downscale to the longest edge the server keeps, re-encoding as JPEG or PNG.
297
+ *
298
+ * Also what normalizes whatever the OS handed the file input — this is how a
299
+ * Safari HEIC becomes a JPEG. Throws if the image cannot be decoded, which is
300
+ * the third dropzone check. */
301
+ export async function talqingDownscaleImage(file: File): Promise<Blob> {
302
+ const bitmap = await createImageBitmap(file);
303
+ try {
304
+ const longest = Math.max(bitmap.width, bitmap.height);
305
+ const scale = longest > TALQING_IMAGE_MAX_EDGE_PX ? TALQING_IMAGE_MAX_EDGE_PX / longest : 1;
306
+ const canvas = document.createElement("canvas");
307
+ canvas.width = Math.max(1, Math.round(bitmap.width * scale));
308
+ canvas.height = Math.max(1, Math.round(bitmap.height * scale));
309
+ const context = canvas.getContext("2d");
310
+ if (!context) throw new Error("this browser cannot resize images");
311
+ context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
312
+ // PNG keeps transparency; everything else becomes a JPEG, including the
313
+ // HEIC the OS decoded for us.
314
+ const type = file.type === "image/png" ? "image/png" : "image/jpeg";
315
+ const blob = await new Promise<Blob | null>((resolve) =>
316
+ canvas.toBlob(resolve, type, 0.85),
317
+ );
318
+ if (!blob) throw new Error("that image could not be read");
319
+ return blob;
320
+ } finally {
321
+ bitmap.close();
322
+ }
323
+ }
324
+
325
+ /** Validate and downscale a file into the `data:` URL a text message carries.
326
+ *
327
+ * `createTextMessage` takes `images: [{data_url, filename}]`; this produces the
328
+ * `data_url`. It doubles as the thumbnail's `src`, so there is no object URL to
329
+ * create and revoke. Throws the sentence to show if the file is refused. */
330
+ export async function talqingImageDataUrl(file: File): Promise<string> {
331
+ const rejection = talqingImageRejection(file);
332
+ if (rejection) throw new Error(rejection);
333
+ const blob = await talqingDownscaleImage(file);
334
+ return await new Promise<string>((resolve, reject) => {
335
+ const reader = new FileReader();
336
+ reader.onerror = () => reject(new Error("that image could not be read"));
337
+ reader.onload = () => resolve(String(reader.result));
338
+ reader.readAsDataURL(blob);
339
+ });
340
+ }
341
+
342
+ /** Send images to the agent on this call, and follow what became of each.
343
+ *
344
+ * `send` resolves once the bytes are on their way; the outcome arrives on the
345
+ * returned list, which is what a thumbnail should render.
346
+ *
347
+ * Takes the `Room` rather than reading it from the session context: a byte
348
+ * stream and an RPC handler are room-level things, and passing the room keeps
349
+ * this usable from a surface that manages its own connection —
350
+ * `useTalqingConnection().room` inside a provider, or the Room you built. */
351
+ export function useTalqingImages(room: Room | null | undefined) {
352
+ const [sent, setSent] = useState<TalqingSentImage[]>([]);
353
+ const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
354
+
355
+ const settle = useCallback((streamId: string, patch: Partial<TalqingSentImage>) => {
356
+ const timer = timers.current.get(streamId);
357
+ if (timer) {
358
+ clearTimeout(timer);
359
+ timers.current.delete(streamId);
360
+ }
361
+ setSent((images) =>
362
+ images.map((image) => (image.streamId === streamId ? { ...image, ...patch } : image)),
363
+ );
364
+ }, []);
365
+
366
+ useEffect(() => {
367
+ if (!room) return;
368
+ room.registerRpcMethod("talqing.image_result", async (data: RpcInvocationData) => {
369
+ const { stream_id, ok, error } = JSON.parse(data.payload || "{}") as {
370
+ stream_id: string;
371
+ ok: boolean;
372
+ error?: string | null;
373
+ };
374
+ settle(stream_id, {
375
+ status: ok ? "sent" : "failed",
376
+ error: ok ? undefined : error || "that image was not accepted",
377
+ });
378
+ return "{}";
379
+ });
380
+ return () => room.unregisterRpcMethod("talqing.image_result");
381
+ }, [room, settle]);
382
+
383
+ const pending = timers.current;
384
+ useEffect(
385
+ () => () => {
386
+ pending.forEach(clearTimeout);
387
+ pending.clear();
388
+ },
389
+ [pending],
390
+ );
391
+
392
+ const send = useCallback(
393
+ async (file: File): Promise<TalqingSentImage> => {
394
+ if (!room) throw new Error("the call is not connected");
395
+ const agent = [...room.remoteParticipants.values()].find(
396
+ (participant: RemoteParticipant) => participant.kind === ParticipantKind.AGENT,
397
+ );
398
+ if (!agent) throw new Error("agent participant is not connected");
399
+ const rejection = talqingImageRejection(file);
400
+ if (rejection) throw new Error(rejection);
401
+
402
+ const blob = await talqingDownscaleImage(file);
403
+ // Addressed to the agent, never broadcast: on video the avatar worker is
404
+ // a participant too, and a broadcast sends the bytes twice.
405
+ const { id } = await room.localParticipant.sendFile(
406
+ new File([blob], file.name, { type: blob.type }),
407
+ {
408
+ topic: TALQING_IMAGE_TOPIC,
409
+ mimeType: blob.type,
410
+ destinationIdentities: [agent.identity],
411
+ },
412
+ );
413
+ const image: TalqingSentImage = {
414
+ streamId: id,
415
+ name: file.name,
416
+ previewUrl: URL.createObjectURL(blob),
417
+ status: "sending",
418
+ };
419
+ setSent((images) => [...images, image]);
420
+ timers.current.set(
421
+ id,
422
+ setTimeout(
423
+ () =>
424
+ settle(id, {
425
+ status: "unknown",
426
+ // Four states, not two: the one people forget is the agent going
427
+ // away mid-upload, where no ack is ever coming.
428
+ error: "we did not hear back — the agent may not have received it",
429
+ }),
430
+ TALQING_IMAGE_ACK_TIMEOUT_MS,
431
+ ),
432
+ );
433
+ return image;
434
+ },
435
+ [room, settle],
436
+ );
437
+
438
+ const clear = useCallback(() => {
439
+ setSent((images) => {
440
+ images.forEach((image) => URL.revokeObjectURL(image.previewUrl));
441
+ return [];
442
+ });
443
+ }, []);
444
+
445
+ return { send, sent, clear };
446
+ }
447
+
448
+ export function useTalqingAgent() {
449
+ const agent = useAgent();
450
+ return {
451
+ state: agent.state,
452
+ isFinished: agent.isFinished,
453
+ };
454
+ }
455
+
456
+ export function useTalqingMessages() {
457
+ const messages = useSessionMessages();
458
+ return {
459
+ messages: messages.messages,
460
+ send: messages.send,
461
+ isSending: messages.isSending,
462
+ };
463
+ }
464
+
465
+ /* Screen share, with the four settings that decide whether the agent can read
466
+ * what is on it.
467
+ *
468
+ * Only the first is obvious, and every one of them is upstream of anything we
469
+ * could do on our side:
470
+ *
471
+ * - `contentHint: "text"` tells the encoder to preserve spatial detail. The
472
+ * default for a screen share on an SVC codec is `motion`, which trades
473
+ * exactly that away — the wrong trade for reading code, and no JPEG quality
474
+ * setting downstream recovers it.
475
+ * - `videoCodec: "h264"` is what keeps that hint. `livekit-client` forces
476
+ * `contentHint = "motion"` on screen shares published as vp9 or av1,
477
+ * together with `scalabilityMode: "L1T3"`. **Publishing on an SVC codec will
478
+ * cost you legibility**, and it is the first thing to check if the agent
479
+ * starts misreading identifiers.
480
+ * - `screenShareEncoding.maxFramerate: 1` is the frame-rate cap that actually
481
+ * binds. `resolution.frameRate` reaches `getDisplayMedia` as a bare number,
482
+ * which WebRTC reads as `ideal` — a wish, not a limit. The default publish
483
+ * ceiling is 15 fps, and every delivered frame is copied into the agent's
484
+ * process before it can be dropped, so this is the single biggest lever on
485
+ * what a call costs the worker. One frame a second is ample: the agent is
486
+ * handed the newest frame at the end of each turn, and turns are seconds
487
+ * apart.
488
+ * - `resolution` is set explicitly rather than inherited. The default is 1080p
489
+ * by `ideal`, which is a strong hint and not a guarantee.
490
+ *
491
+ * The picker cannot be opened for the person — `getDisplayMedia` requires a
492
+ * transient user activation — so `start()` must be called from a click. The
493
+ * agent asking out loud is what prompts it; there is no version of this where
494
+ * the agent turns sharing on.
495
+ */
496
+ export const TALQING_SCREEN_SHARE_CAPTURE: ScreenShareCaptureOptions = {
497
+ resolution: { width: 1920, height: 1080, frameRate: 1 },
498
+ contentHint: "text",
499
+ };
500
+
501
+ export const TALQING_SCREEN_SHARE_PUBLISH: TrackPublishOptions = {
502
+ videoCodec: "h264",
503
+ screenShareEncoding: { maxBitrate: 1_000_000, maxFramerate: 1 },
504
+ };
505
+
506
+ export function useTalqingScreenShare() {
507
+ const { toggle, enabled, pending } = useTrackToggle({
508
+ source: Track.Source.ScreenShare,
509
+ captureOptions: TALQING_SCREEN_SHARE_CAPTURE,
510
+ publishOptions: TALQING_SCREEN_SHARE_PUBLISH,
511
+ });
512
+ return useMemo(
513
+ () => ({
514
+ /** Whether a screen is being shared right now. */
515
+ enabled,
516
+ /** True while the browser picker is open or the track is publishing. */
517
+ pending,
518
+ /** Opens the browser's share picker. Must be called from a user gesture. */
519
+ start: () => toggle(true),
520
+ /** Stops sharing. The agent is told it can no longer see on the next turn. */
521
+ stop: () => toggle(false),
522
+ }),
523
+ [enabled, pending, toggle],
524
+ );
525
+ }
526
+
527
+ export function useTalqingAvatarTrack(): TalqingVideoTrackRef | undefined {
528
+ const { agent } = useVoiceAssistant();
529
+ const remoteParticipants = useRemoteParticipants();
530
+ const worker = remoteParticipants.find(
531
+ (participant: RemoteParticipant) =>
532
+ participant.kind === ParticipantKind.AGENT &&
533
+ participant.attributes["lk.publish_on_behalf"] === agent?.identity,
534
+ );
535
+
536
+ const agentTracks = useParticipantTracks(
537
+ [Track.Source.Camera, Track.Source.ScreenShare],
538
+ agent?.identity,
539
+ );
540
+ const workerTracks = useParticipantTracks(
541
+ [Track.Source.Camera, Track.Source.ScreenShare],
542
+ worker?.identity,
543
+ );
544
+
545
+ return (
546
+ workerTracks.find(
547
+ (track: TrackReference) => track.source === Track.Source.Camera,
548
+ ) ??
549
+ workerTracks.find(
550
+ (track: TrackReference) => track.source === Track.Source.ScreenShare,
551
+ ) ??
552
+ agentTracks.find(
553
+ (track: TrackReference) => track.source === Track.Source.Camera,
554
+ ) ??
555
+ agentTracks.find(
556
+ (track: TrackReference) => track.source === Track.Source.ScreenShare,
557
+ )
558
+ );
559
+ }
560
+
561
+ export function TalqingVideoTrack({
562
+ track,
563
+ className,
564
+ }: {
565
+ track: TalqingVideoTrackRef;
566
+ className?: string;
567
+ }) {
568
+ return (
569
+ <VideoTrack
570
+ trackRef={track}
571
+ width={track.publication.dimensions?.width ?? 0}
572
+ height={track.publication.dimensions?.height ?? 0}
573
+ className={className}
574
+ />
575
+ );
576
+ }
577
+
578
+ export function useTalqingRpcHandlers(
579
+ handlers: TalqingRpcHandlers,
580
+ enabled = true,
581
+ ) {
582
+ const { room, isConnected } = useTalqingConnection();
583
+
584
+ useEffect(() => {
585
+ if (!enabled || !room || !isConnected) return;
586
+
587
+ room.registerRpcMethod(
588
+ "talqing.frontend_rpc",
589
+ async (data: RpcInvocationData) => {
590
+ try {
591
+ const envelope = JSON.parse(
592
+ data.payload || "{}",
593
+ ) as TalqingFrontendRpcEnvelope;
594
+ const isWildcard = handlers[envelope.method] === undefined;
595
+ const handler = handlers[envelope.method] ?? handlers["*"];
596
+ if (!handler) {
597
+ throw new TalqingRpcError(
598
+ 1404,
599
+ `No frontend RPC handler registered for ${envelope.method}`,
600
+ );
601
+ }
602
+ return await handler({
603
+ payload: isWildcard
604
+ ? JSON.stringify(envelope)
605
+ : envelope.payload === undefined
606
+ ? "{}"
607
+ : JSON.stringify(envelope.payload),
608
+ responseTimeout: data.responseTimeout,
609
+ callerIdentity: data.callerIdentity,
610
+ });
611
+ } catch (error) {
612
+ if (error instanceof TalqingRpcError) {
613
+ throw new RpcError(error.code, error.message, error.data);
614
+ }
615
+ throw new RpcError(
616
+ 1500,
617
+ error instanceof Error ? error.message : "RPC handler failed",
618
+ );
619
+ }
620
+ },
621
+ );
622
+
623
+ return () => {
624
+ room.unregisterRpcMethod("talqing.frontend_rpc");
625
+ };
626
+ }, [enabled, handlers, isConnected, room]);
627
+ }
628
+
629
+ export function useTalqingFrontendRpcs(enabled = true) {
630
+ const [actions, setActions] = useState<
631
+ Record<string, { payload?: JsonValue; at: number }>
632
+ >({});
633
+
634
+ const handlers = useMemo<TalqingRpcHandlers>(
635
+ () => ({
636
+ "*": async (data: TalqingRpcInvocation) => {
637
+ const payload = JSON.parse(data.payload || "{}") as JsonValue;
638
+ const method = String((payload as { method?: string }).method || "");
639
+ if (method) {
640
+ setActions(
641
+ (current: Record<string, { payload?: JsonValue; at: number }>) => ({
642
+ ...current,
643
+ [method]: {
644
+ payload: (payload as { payload?: JsonValue }).payload,
645
+ at: Date.now(),
646
+ },
647
+ }),
648
+ );
649
+ }
650
+ return "ok";
651
+ },
652
+ }),
653
+ [],
654
+ );
655
+
656
+ useTalqingRpcHandlers(handlers, enabled);
657
+
658
+ return {
659
+ actions,
660
+ clear: () => setActions({}),
661
+ };
662
+ }
663
+
664
+ export function setTalqingBrowserLogLevel(
665
+ level: "debug" | "info" | "warn" | "error" | "silent",
666
+ ) {
667
+ const levels = {
668
+ debug: LogLevel.debug,
669
+ info: LogLevel.info,
670
+ warn: LogLevel.warn,
671
+ error: LogLevel.error,
672
+ silent: LogLevel.silent,
673
+ };
674
+ setLogLevel(levels[level]);
675
+ }