@talqing/client 0.4.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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/dist/cjs/images.d.ts +62 -0
  4. package/dist/cjs/images.d.ts.map +1 -0
  5. package/dist/cjs/images.js +137 -0
  6. package/dist/cjs/images.js.map +1 -0
  7. package/dist/cjs/index.d.ts +10 -0
  8. package/dist/cjs/index.d.ts.map +1 -0
  9. package/dist/cjs/index.js +37 -0
  10. package/dist/cjs/index.js.map +1 -0
  11. package/dist/cjs/json.d.ts +8 -0
  12. package/dist/cjs/json.d.ts.map +1 -0
  13. package/dist/cjs/json.js +3 -0
  14. package/dist/cjs/json.js.map +1 -0
  15. package/dist/cjs/media.d.ts +9 -0
  16. package/dist/cjs/media.d.ts.map +1 -0
  17. package/dist/cjs/media.js +53 -0
  18. package/dist/cjs/media.js.map +1 -0
  19. package/dist/cjs/package.json +3 -0
  20. package/dist/cjs/rpc.d.ts +25 -0
  21. package/dist/cjs/rpc.d.ts.map +1 -0
  22. package/dist/cjs/rpc.js +48 -0
  23. package/dist/cjs/rpc.js.map +1 -0
  24. package/dist/cjs/token.d.ts +29 -0
  25. package/dist/cjs/token.d.ts.map +1 -0
  26. package/dist/cjs/token.js +40 -0
  27. package/dist/cjs/token.js.map +1 -0
  28. package/dist/cjs/userdata.d.ts +22 -0
  29. package/dist/cjs/userdata.d.ts.map +1 -0
  30. package/dist/cjs/userdata.js +51 -0
  31. package/dist/cjs/userdata.js.map +1 -0
  32. package/dist/esm/images.d.ts +62 -0
  33. package/dist/esm/images.d.ts.map +1 -0
  34. package/dist/esm/images.js +129 -0
  35. package/dist/esm/images.js.map +1 -0
  36. package/dist/esm/index.d.ts +10 -0
  37. package/dist/esm/index.d.ts.map +1 -0
  38. package/dist/esm/index.js +16 -0
  39. package/dist/esm/index.js.map +1 -0
  40. package/dist/esm/json.d.ts +8 -0
  41. package/dist/esm/json.d.ts.map +1 -0
  42. package/dist/esm/json.js +2 -0
  43. package/dist/esm/json.js.map +1 -0
  44. package/dist/esm/media.d.ts +9 -0
  45. package/dist/esm/media.d.ts.map +1 -0
  46. package/dist/esm/media.js +50 -0
  47. package/dist/esm/media.js.map +1 -0
  48. package/dist/esm/package.json +3 -0
  49. package/dist/esm/rpc.d.ts +25 -0
  50. package/dist/esm/rpc.d.ts.map +1 -0
  51. package/dist/esm/rpc.js +43 -0
  52. package/dist/esm/rpc.js.map +1 -0
  53. package/dist/esm/token.d.ts +29 -0
  54. package/dist/esm/token.d.ts.map +1 -0
  55. package/dist/esm/token.js +37 -0
  56. package/dist/esm/token.js.map +1 -0
  57. package/dist/esm/userdata.d.ts +22 -0
  58. package/dist/esm/userdata.d.ts.map +1 -0
  59. package/dist/esm/userdata.js +46 -0
  60. package/dist/esm/userdata.js.map +1 -0
  61. package/package.json +62 -0
  62. package/src/images.ts +160 -0
  63. package/src/index.ts +48 -0
  64. package/src/json.ts +17 -0
  65. package/src/media.ts +53 -0
  66. package/src/rpc.ts +81 -0
  67. package/src/token.ts +49 -0
  68. package/src/userdata.ts +57 -0
@@ -0,0 +1,22 @@
1
+ import { type RemoteParticipant, type Room } from "livekit-client";
2
+ import type { JsonObject } from "./json.js";
3
+ /** The agent on this call, or undefined before it has joined.
4
+ *
5
+ * On a video call the avatar worker is an agent participant too, but it joins
6
+ * on the agent's behalf and never answers RPCs, so the first one found is the
7
+ * one to talk to. */
8
+ export declare function talqingAgentParticipant(room: Room): RemoteParticipant | undefined;
9
+ /** Merge a patch into the live session's userdata, over RPC to the agent.
10
+ *
11
+ * Resolves rather than throws when the agent is not there: a write that did
12
+ * not land is a state a page can render. */
13
+ export declare function setTalqingUserdata(room: Room, patch: JsonObject, responseTimeout?: number): Promise<{
14
+ ok: boolean;
15
+ error?: string;
16
+ }>;
17
+ /** Read the live session's userdata, or only the keys named.
18
+ *
19
+ * Throws when the agent is not there, unlike `setTalqingUserdata`: a read that
20
+ * returned nothing is not a state anything can render. */
21
+ export declare function getTalqingUserdata(room: Room, keys?: string[], responseTimeout?: number): Promise<JsonObject>;
22
+ //# sourceMappingURL=userdata.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"userdata.d.ts","sourceRoot":"","sources":["../../src/userdata.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmB,KAAK,iBAAiB,EAAE,KAAK,IAAI,EAAE,MAAM,gBAAgB,CAAC;AACpF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAE5C;;;;sBAIsB;AACtB,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,IAAI,GAAG,iBAAiB,GAAG,SAAS,CAIjF;AAED;;;6CAG6C;AAC7C,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,IAAI,EACV,KAAK,EAAE,UAAU,EACjB,eAAe,SAAI,GAClB,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAU1C;AAED;;;2DAG2D;AAC3D,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,IAAI,EACV,IAAI,CAAC,EAAE,MAAM,EAAE,EACf,eAAe,SAAI,GAClB,OAAO,CAAC,UAAU,CAAC,CAcrB"}
@@ -0,0 +1,46 @@
1
+ import { ParticipantKind } from "livekit-client";
2
+ /** The agent on this call, or undefined before it has joined.
3
+ *
4
+ * On a video call the avatar worker is an agent participant too, but it joins
5
+ * on the agent's behalf and never answers RPCs, so the first one found is the
6
+ * one to talk to. */
7
+ export function talqingAgentParticipant(room) {
8
+ return [...room.remoteParticipants.values()].find((participant) => participant.kind === ParticipantKind.AGENT);
9
+ }
10
+ /** Merge a patch into the live session's userdata, over RPC to the agent.
11
+ *
12
+ * Resolves rather than throws when the agent is not there: a write that did
13
+ * not land is a state a page can render. */
14
+ export async function setTalqingUserdata(room, patch, responseTimeout = 3) {
15
+ const agent = talqingAgentParticipant(room);
16
+ if (!agent)
17
+ return { ok: false, error: "agent participant is not connected" };
18
+ const response = await room.localParticipant.performRpc({
19
+ destinationIdentity: agent.identity,
20
+ method: "talqing.client.userdata_set",
21
+ payload: JSON.stringify(patch),
22
+ responseTimeout,
23
+ });
24
+ return JSON.parse(response || "{}");
25
+ }
26
+ /** Read the live session's userdata, or only the keys named.
27
+ *
28
+ * Throws when the agent is not there, unlike `setTalqingUserdata`: a read that
29
+ * returned nothing is not a state anything can render. */
30
+ export async function getTalqingUserdata(room, keys, responseTimeout = 3) {
31
+ const agent = talqingAgentParticipant(room);
32
+ if (!agent)
33
+ throw new Error("agent participant is not connected");
34
+ const response = await room.localParticipant.performRpc({
35
+ destinationIdentity: agent.identity,
36
+ method: "talqing.client.userdata_get",
37
+ payload: JSON.stringify(keys ? { keys } : {}),
38
+ responseTimeout,
39
+ });
40
+ const parsed = JSON.parse(response || "{}");
41
+ if (parsed.ok === false) {
42
+ throw new Error(String(parsed.error || "userdata RPC failed"));
43
+ }
44
+ return parsed;
45
+ }
46
+ //# sourceMappingURL=userdata.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"userdata.js","sourceRoot":"","sources":["../../src/userdata.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAqC,MAAM,gBAAgB,CAAC;AAGpF;;;;sBAIsB;AACtB,MAAM,UAAU,uBAAuB,CAAC,IAAU;IAChD,OAAO,CAAC,GAAG,IAAI,CAAC,kBAAkB,CAAC,MAAM,EAAE,CAAC,CAAC,IAAI,CAC/C,CAAC,WAA8B,EAAE,EAAE,CAAC,WAAW,CAAC,IAAI,KAAK,eAAe,CAAC,KAAK,CAC/E,CAAC;AACJ,CAAC;AAED;;;6CAG6C;AAC7C,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,IAAU,EACV,KAAiB,EACjB,eAAe,GAAG,CAAC;IAEnB,MAAM,KAAK,GAAG,uBAAuB,CAAC,IAAI,CAAC,CAAC;IAC5C,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,oCAAoC,EAAE,CAAC;IAC9E,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,gBAAgB,CAAC,UAAU,CAAC;QACtD,mBAAmB,EAAE,KAAK,CAAC,QAAQ;QACnC,MAAM,EAAE,6BAA6B;QACrC,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC;QAC9B,eAAe;KAChB,CAAC,CAAC;IACH,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,IAAI,IAAI,CAAoC,CAAC;AACzE,CAAC;AAED;;;2DAG2D;AAC3D,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,IAAU,EACV,IAAe,EACf,eAAe,GAAG,CAAC;IAEnB,MAAM,KAAK,GAAG,uBAAuB,CAAC,IAAI,CAAC,CAAC;IAC5C,IAAI,CAAC,KAAK;QAAE,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC;IAClE,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,gBAAgB,CAAC,UAAU,CAAC;QACtD,mBAAmB,EAAE,KAAK,CAAC,QAAQ;QACnC,MAAM,EAAE,6BAA6B;QACrC,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7C,eAAe;KAChB,CAAC,CAAC;IACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,QAAQ,IAAI,IAAI,CAAkD,CAAC;IAC7F,IAAI,MAAM,CAAC,EAAE,KAAK,KAAK,EAAE,CAAC;QACxB,MAAM,IAAI,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,IAAI,qBAAqB,CAAC,CAAC,CAAC;IACjE,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@talqing/client",
3
+ "version": "0.4.0",
4
+ "description": "The Talqing call protocol over a LiveKit room — token, userdata, images, screen share and frontend RPC. Framework-free.",
5
+ "license": "MIT",
6
+ "homepage": "https://talqing.com",
7
+ "bugs": {
8
+ "email": "hello@talqing.com"
9
+ },
10
+ "keywords": [
11
+ "talqing",
12
+ "ai",
13
+ "agents",
14
+ "voice",
15
+ "video",
16
+ "livekit",
17
+ "webrtc"
18
+ ],
19
+ "type": "module",
20
+ "sideEffects": false,
21
+ "main": "./dist/cjs/index.js",
22
+ "module": "./dist/esm/index.js",
23
+ "types": "./dist/cjs/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "import": {
27
+ "types": "./dist/esm/index.d.ts",
28
+ "default": "./dist/esm/index.js"
29
+ },
30
+ "require": {
31
+ "types": "./dist/cjs/index.d.ts",
32
+ "default": "./dist/cjs/index.js"
33
+ }
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "src",
40
+ "README.md"
41
+ ],
42
+ "engines": {
43
+ "node": ">=20"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "scripts": {
49
+ "build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node ../../scripts/dist-markers.mjs",
50
+ "dev": "tsc -w -p tsconfig.json",
51
+ "typecheck": "tsc --noEmit -p tsconfig.json",
52
+ "prepublishOnly": "npm run build"
53
+ },
54
+ "dependencies": {},
55
+ "peerDependencies": {
56
+ "livekit-client": "^2.19.1"
57
+ },
58
+ "devDependencies": {
59
+ "livekit-client": "^2.19.1",
60
+ "typescript": "~5.9.3"
61
+ }
62
+ }
package/src/images.ts ADDED
@@ -0,0 +1,160 @@
1
+ import type { Room, RpcInvocationData } from "livekit-client";
2
+ import { talqingAgentParticipant } from "./userdata.js";
3
+
4
+ /* ── images ───────────────────────────────────────────────────────────────
5
+ *
6
+ * A caller attaches a photo on a web voice or video call and the agent sees
7
+ * it. The bytes go over a LiveKit byte stream on the `talqing.images` topic,
8
+ * addressed to the agent; the agent answers on an RPC back, and the id
9
+ * `sendFile` returns is the id that ack carries, so there is nothing to
10
+ * correlate by hand.
11
+ *
12
+ * The agent deliberately does NOT speak when a photo lands — the caller is
13
+ * mid-conversation and about to say what it is for. The image is in the
14
+ * model's view for their next turn.
15
+ */
16
+
17
+ export const TALQING_IMAGE_TOPIC = "talqing.images";
18
+ /** Decoded size the server refuses above. The downscale below keeps a phone
19
+ * photo far under it; this is the belt for a file that is not a photo. */
20
+ export const TALQING_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
21
+ /** Longest side the server stores. Downscaling to it before sending is a
22
+ * courtesy to the data channel, which is ordered and reliable and will
23
+ * happily spend a minute pushing 10 MB past a call's audio. */
24
+ export const TALQING_IMAGE_MAX_EDGE_PX = 1568;
25
+ const TALQING_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"] as const;
26
+ /** How long to wait for the agent's ack before saying we do not know. Without
27
+ * it a thumbnail sits on "sending" for the rest of the call when the worker
28
+ * dies mid-upload. */
29
+ export const TALQING_IMAGE_ACK_TIMEOUT_MS = 30_000;
30
+
31
+ /** Refuse what the browser can see is wrong, instantly and with no round trip.
32
+ * Returns the sentence to show, or null when the file is fine. The server
33
+ * re-validates everything regardless — this exists so the common mistakes cost
34
+ * nothing to catch. */
35
+ export function talqingImageRejection(file: File): string | null {
36
+ if (!(TALQING_IMAGE_TYPES as readonly string[]).includes(file.type)) {
37
+ return "that file is not a JPEG, PNG or WebP";
38
+ }
39
+ if (file.size > TALQING_IMAGE_MAX_BYTES) return "images are 10 MB or smaller";
40
+ return null;
41
+ }
42
+
43
+ /** Downscale to the longest edge the server keeps, re-encoding as JPEG or PNG.
44
+ *
45
+ * Also what normalizes whatever the OS handed the file input — this is how a
46
+ * Safari HEIC becomes a JPEG. Throws if the image cannot be decoded, which is
47
+ * the third dropzone check. */
48
+ export async function talqingDownscaleImage(file: File): Promise<Blob> {
49
+ const bitmap = await createImageBitmap(file);
50
+ try {
51
+ const longest = Math.max(bitmap.width, bitmap.height);
52
+ const scale = longest > TALQING_IMAGE_MAX_EDGE_PX ? TALQING_IMAGE_MAX_EDGE_PX / longest : 1;
53
+ const canvas = document.createElement("canvas");
54
+ canvas.width = Math.max(1, Math.round(bitmap.width * scale));
55
+ canvas.height = Math.max(1, Math.round(bitmap.height * scale));
56
+ const context = canvas.getContext("2d");
57
+ if (!context) throw new Error("this browser cannot resize images");
58
+ context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
59
+ // PNG keeps transparency; everything else becomes a JPEG, including the
60
+ // HEIC the OS decoded for us.
61
+ const type = file.type === "image/png" ? "image/png" : "image/jpeg";
62
+ const blob = await new Promise<Blob | null>((resolve) =>
63
+ canvas.toBlob(resolve, type, 0.85),
64
+ );
65
+ if (!blob) throw new Error("that image could not be read");
66
+ return blob;
67
+ } finally {
68
+ bitmap.close();
69
+ }
70
+ }
71
+
72
+ /** Validate and downscale a file into the `data:` URL a text message carries.
73
+ *
74
+ * `createTextMessage` takes `images: [{data_url, filename}]`; this produces the
75
+ * `data_url`. It doubles as the thumbnail's `src`, so there is no object URL to
76
+ * create and revoke. Throws the sentence to show if the file is refused. */
77
+ export async function talqingImageDataUrl(file: File): Promise<string> {
78
+ const rejection = talqingImageRejection(file);
79
+ if (rejection) throw new Error(rejection);
80
+ const blob = await talqingDownscaleImage(file);
81
+ return await new Promise<string>((resolve, reject) => {
82
+ const reader = new FileReader();
83
+ reader.onerror = () => reject(new Error("that image could not be read"));
84
+ reader.onload = () => resolve(String(reader.result));
85
+ reader.readAsDataURL(blob);
86
+ });
87
+ }
88
+
89
+ export type TalqingImageStatus = "sending" | "sent" | "failed" | "unknown";
90
+
91
+ /** One image the browser sent on a call, and what became of it. */
92
+ export interface TalqingSentImage {
93
+ /** The stream id `sendFile` returned, which the ack comes back under. */
94
+ streamId: string;
95
+ name: string;
96
+ /** An object URL for the downscaled image, for rendering the thumbnail.
97
+ * Revoke it when you are done with it — `useTalqingImages` does. */
98
+ previewUrl: string;
99
+ /** When it was handed to the room, so a transcript can put it in the sequence
100
+ * it was sent in rather than at the end. */
101
+ sentAt: number;
102
+ status: TalqingImageStatus;
103
+ /** Set when `status` is "failed" or "unknown" — a sentence, shown as-is. */
104
+ error?: string;
105
+ }
106
+
107
+ /** Validate, downscale and send one image to the agent on this call.
108
+ *
109
+ * Resolves once the bytes are on their way, with the entry to render. The
110
+ * outcome arrives later, on the ack — see `onTalqingImageResult`. Throws the
111
+ * sentence to show if the file is refused or the agent is not there yet. */
112
+ export async function sendTalqingImage(room: Room, file: File): Promise<TalqingSentImage> {
113
+ const agent = talqingAgentParticipant(room);
114
+ if (!agent) throw new Error("the agent is not on the call yet");
115
+ const rejection = talqingImageRejection(file);
116
+ if (rejection) throw new Error(rejection);
117
+
118
+ const blob = await talqingDownscaleImage(file);
119
+ // Addressed to the agent, never broadcast: on video the avatar worker is
120
+ // a participant too, and a broadcast sends the bytes twice.
121
+ const { id } = await room.localParticipant.sendFile(
122
+ new File([blob], file.name, { type: blob.type }),
123
+ {
124
+ topic: TALQING_IMAGE_TOPIC,
125
+ mimeType: blob.type,
126
+ destinationIdentities: [agent.identity],
127
+ },
128
+ );
129
+ return {
130
+ streamId: id,
131
+ name: file.name,
132
+ previewUrl: URL.createObjectURL(blob),
133
+ sentAt: Date.now(),
134
+ status: "sending",
135
+ };
136
+ }
137
+
138
+ /** Listen for the agent's verdict on each image sent. Returns the unregister.
139
+ *
140
+ * There is one ack per image even on the happy path, because the agent stays
141
+ * silent when a photo lands — so silence cannot double as "it arrived". */
142
+ export function onTalqingImageResult(
143
+ room: Room,
144
+ onResult: (result: { streamId: string; ok: boolean; error?: string }) => void,
145
+ ): () => void {
146
+ room.registerRpcMethod("talqing.image_result", async (data: RpcInvocationData) => {
147
+ const { stream_id, ok, error } = JSON.parse(data.payload || "{}") as {
148
+ stream_id: string;
149
+ ok: boolean;
150
+ error?: string | null;
151
+ };
152
+ onResult({
153
+ streamId: stream_id,
154
+ ok,
155
+ error: ok ? undefined : error || "that image was not accepted",
156
+ });
157
+ return "{}";
158
+ });
159
+ return () => room.unregisterRpcMethod("talqing.image_result");
160
+ }
package/src/index.ts ADDED
@@ -0,0 +1,48 @@
1
+ /* The Talqing call protocol, over a LiveKit `Room`. Framework-free.
2
+ *
3
+ * Everything here is something only Talqing can write: the mint-once token
4
+ * source, the two userdata RPC methods, the `talqing.images` byte-stream topic
5
+ * and its ack, the `talqing.frontend_rpc` envelope, and the measured capture
6
+ * settings. Nothing here renames LiveKit — for a room, a track or a
7
+ * participant, use `livekit-client` directly.
8
+ *
9
+ * React bindings for all of it are `@talqing/react`.
10
+ */
11
+
12
+ export { createTalqingTokenSource } from "./token.js";
13
+ export type { TalqingCallCredentials } from "./token.js";
14
+
15
+ export {
16
+ getTalqingUserdata,
17
+ setTalqingUserdata,
18
+ talqingAgentParticipant,
19
+ } from "./userdata.js";
20
+
21
+ export {
22
+ onTalqingImageResult,
23
+ sendTalqingImage,
24
+ talqingDownscaleImage,
25
+ talqingImageDataUrl,
26
+ talqingImageRejection,
27
+ TALQING_IMAGE_ACK_TIMEOUT_MS,
28
+ TALQING_IMAGE_MAX_BYTES,
29
+ TALQING_IMAGE_MAX_EDGE_PX,
30
+ TALQING_IMAGE_TOPIC,
31
+ } from "./images.js";
32
+ export type { TalqingImageStatus, TalqingSentImage } from "./images.js";
33
+
34
+ export { registerTalqingRpcHandlers, TalqingRpcError } from "./rpc.js";
35
+ export type {
36
+ TalqingFrontendRpcEnvelope,
37
+ TalqingRpcHandler,
38
+ TalqingRpcHandlers,
39
+ TalqingRpcInvocation,
40
+ } from "./rpc.js";
41
+
42
+ export {
43
+ TALQING_AUDIO_CAPTURE_DEFAULTS,
44
+ TALQING_SCREEN_SHARE_CAPTURE,
45
+ TALQING_SCREEN_SHARE_PUBLISH,
46
+ } from "./media.js";
47
+
48
+ export type { JsonObject, JsonPrimitive, JsonValue } from "./json.js";
package/src/json.ts ADDED
@@ -0,0 +1,17 @@
1
+ /* Free-form JSON: userdata, a frontend-RPC payload.
2
+ *
3
+ * `@talqing/sdk` declares the same three aliases for the same reason, and this
4
+ * package deliberately does not import them from there: the whole point of the
5
+ * split is that the call protocol needs no API client. These are structural
6
+ * aliases, so the two spellings are the same type to a consumer holding both.
7
+ *
8
+ * `JsonObject` is exactly what the API says such a field is — an object of
9
+ * `unknown` — so a value read off a response drops straight into it. Naming it
10
+ * anything narrower would assert a guarantee the API does not make.
11
+ *
12
+ * `JsonValue` is the recursive form, for a payload the CALLER builds: there it
13
+ * is a real constraint, because it refuses a `Date` or a class instance that
14
+ * would not survive `JSON.stringify`. */
15
+ export type JsonPrimitive = string | number | boolean | null;
16
+ export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
17
+ export type JsonObject = { [key: string]: unknown };
package/src/media.ts ADDED
@@ -0,0 +1,53 @@
1
+ import type { ScreenShareCaptureOptions, TrackPublishOptions } from "livekit-client";
2
+
3
+ /* Echo cancellation is what stops the agent hearing its own speech through the
4
+ * caller's microphone and interrupting itself. It is a default rather than
5
+ * something each app remembers, because a call without it is broken in a way
6
+ * that sounds like a bad model. Pass `roomOptions.audioCaptureDefaults` to
7
+ * override. */
8
+ export const TALQING_AUDIO_CAPTURE_DEFAULTS = {
9
+ echoCancellation: true,
10
+ noiseSuppression: true,
11
+ autoGainControl: true,
12
+ } as const;
13
+
14
+ /* Screen share, with the four settings that decide whether the agent can read
15
+ * what is on it.
16
+ *
17
+ * Only the first is obvious, and every one of them is upstream of anything we
18
+ * could do on our side:
19
+ *
20
+ * - `contentHint: "text"` tells the encoder to preserve spatial detail. The
21
+ * default for a screen share on an SVC codec is `motion`, which trades
22
+ * exactly that away — the wrong trade for reading code, and no JPEG quality
23
+ * setting downstream recovers it.
24
+ * - `videoCodec: "h264"` is what keeps that hint. `livekit-client` forces
25
+ * `contentHint = "motion"` on screen shares published as vp9 or av1,
26
+ * together with `scalabilityMode: "L1T3"`. **Publishing on an SVC codec will
27
+ * cost you legibility**, and it is the first thing to check if the agent
28
+ * starts misreading identifiers.
29
+ * - `screenShareEncoding.maxFramerate: 1` is the frame-rate cap that actually
30
+ * binds. `resolution.frameRate` reaches `getDisplayMedia` as a bare number,
31
+ * which WebRTC reads as `ideal` — a wish, not a limit. The default publish
32
+ * ceiling is 15 fps, and every delivered frame is copied into the agent's
33
+ * process before it can be dropped, so this is the single biggest lever on
34
+ * what a call costs the worker. One frame a second is ample: the agent is
35
+ * handed the newest frame at the end of each turn, and turns are seconds
36
+ * apart.
37
+ * - `resolution` is set explicitly rather than inherited. The default is 1080p
38
+ * by `ideal`, which is a strong hint and not a guarantee.
39
+ *
40
+ * The picker cannot be opened for the person — `getDisplayMedia` requires a
41
+ * transient user activation — so sharing must start from a click. The agent
42
+ * asking out loud is what prompts it; there is no version of this where the
43
+ * agent turns sharing on.
44
+ */
45
+ export const TALQING_SCREEN_SHARE_CAPTURE: ScreenShareCaptureOptions = {
46
+ resolution: { width: 1920, height: 1080, frameRate: 1 },
47
+ contentHint: "text",
48
+ };
49
+
50
+ export const TALQING_SCREEN_SHARE_PUBLISH: TrackPublishOptions = {
51
+ videoCodec: "h264",
52
+ screenShareEncoding: { maxBitrate: 1_000_000, maxFramerate: 1 },
53
+ };
package/src/rpc.ts ADDED
@@ -0,0 +1,81 @@
1
+ import { RpcError, type Room, type RpcInvocationData } from "livekit-client";
2
+ import type { JsonValue } from "./json.js";
3
+
4
+ /* ── frontend RPC ─────────────────────────────────────────────────────────
5
+ *
6
+ * A tool's `frontend_rpc` operation calls back into the page mid-call. The
7
+ * agent RPCs ONE method — `talqing.frontend_rpc` — carrying an envelope that
8
+ * names the method the page should run, so a page registers once and dispatches
9
+ * on `envelope.method` rather than registering a room method per tool.
10
+ */
11
+
12
+ export interface TalqingRpcInvocation {
13
+ payload: string;
14
+ responseTimeout?: number;
15
+ callerIdentity?: string;
16
+ }
17
+
18
+ export type TalqingRpcHandler = (
19
+ data: TalqingRpcInvocation,
20
+ ) => string | Promise<string>;
21
+ export type TalqingRpcHandlers = Record<string, TalqingRpcHandler>;
22
+
23
+ export interface TalqingFrontendRpcEnvelope {
24
+ method: string;
25
+ payload?: JsonValue;
26
+ }
27
+
28
+ /** Throw this from a handler to choose the code the agent sees. */
29
+ export class TalqingRpcError extends Error {
30
+ readonly code: number;
31
+ readonly data?: string;
32
+
33
+ constructor(code: number, message: string, data?: string) {
34
+ super(message);
35
+ this.name = "TalqingRpcError";
36
+ this.code = code;
37
+ this.data = data;
38
+ }
39
+ }
40
+
41
+ /** Register the frontend-RPC envelope handler on a room. Returns the unregister.
42
+ *
43
+ * A `"*"` key catches every method with no handler of its own, and receives the
44
+ * WHOLE envelope as its payload string so it can tell what it caught. */
45
+ export function registerTalqingRpcHandlers(
46
+ room: Room,
47
+ handlers: TalqingRpcHandlers,
48
+ ): () => void {
49
+ room.registerRpcMethod("talqing.frontend_rpc", async (data: RpcInvocationData) => {
50
+ try {
51
+ const envelope = JSON.parse(data.payload || "{}") as TalqingFrontendRpcEnvelope;
52
+ const isWildcard = handlers[envelope.method] === undefined;
53
+ const handler = handlers[envelope.method] ?? handlers["*"];
54
+ if (!handler) {
55
+ throw new TalqingRpcError(
56
+ 1404,
57
+ `No frontend RPC handler registered for ${envelope.method}`,
58
+ );
59
+ }
60
+ return await handler({
61
+ payload: isWildcard
62
+ ? JSON.stringify(envelope)
63
+ : envelope.payload === undefined
64
+ ? "{}"
65
+ : JSON.stringify(envelope.payload),
66
+ responseTimeout: data.responseTimeout,
67
+ callerIdentity: data.callerIdentity,
68
+ });
69
+ } catch (error) {
70
+ if (error instanceof TalqingRpcError) {
71
+ throw new RpcError(error.code, error.message, error.data);
72
+ }
73
+ throw new RpcError(
74
+ 1500,
75
+ error instanceof Error ? error.message : "RPC handler failed",
76
+ );
77
+ }
78
+ });
79
+
80
+ return () => room.unregisterRpcMethod("talqing.frontend_rpc");
81
+ }
package/src/token.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { TokenSource, type TokenSourceFixed } from "livekit-client";
2
+
3
+ /** The two fields a browser needs to join a room, and nothing else.
4
+ *
5
+ * Named as `POST /v1/calls/token` names them, so a server that mints one can
6
+ * forward the response through to the browser without renaming anything. */
7
+ export interface TalqingCallCredentials {
8
+ server_url: string;
9
+ participant_token: string;
10
+ }
11
+
12
+ /** Turn a one-shot mint into the token source LiveKit's session takes.
13
+ *
14
+ * `fetchToken` is called at most once per source, no matter how often LiveKit
15
+ * asks. That is load-bearing rather than an optimization: minting is not free,
16
+ * because `POST /v1/calls/token` files the session row the call is later read
17
+ * back from. The framework asks three times per call — to warm the connection,
18
+ * to connect, and once more as the call ends to drop what it assumes is a
19
+ * cached token — and only the middle one is a call anybody placed. Minting for
20
+ * the third filed a session nobody ever joined, which sat in the workspace's
21
+ * call list as queued for good.
22
+ *
23
+ * `TokenSource.literal` is the LiveKit variant that leaves caching to us, and
24
+ * this is us doing it.
25
+ *
26
+ * Freshness comes from a new source per call, never from re-minting inside
27
+ * one: the token names the room, and this call has exactly one. Mount the
28
+ * session per call and end it on unmount, as LiveKit's own session lifecycle
29
+ * does, and that holds by construction. */
30
+ export function createTalqingTokenSource(
31
+ fetchToken: () => Promise<TalqingCallCredentials>,
32
+ ): TokenSourceFixed {
33
+ let minted: Promise<{ serverUrl: string; participantToken: string }> | null = null;
34
+
35
+ return TokenSource.literal(() => {
36
+ // A mint that failed is not this call's token, and the surface will offer
37
+ // the call again — so keep the rejection out of the cache.
38
+ minted ??= fetchToken()
39
+ .then((credentials) => ({
40
+ serverUrl: credentials.server_url,
41
+ participantToken: credentials.participant_token,
42
+ }))
43
+ .catch((error: unknown) => {
44
+ minted = null;
45
+ throw error;
46
+ });
47
+ return minted;
48
+ });
49
+ }
@@ -0,0 +1,57 @@
1
+ import { ParticipantKind, type RemoteParticipant, type Room } from "livekit-client";
2
+ import type { JsonObject } from "./json.js";
3
+
4
+ /** The agent on this call, or undefined before it has joined.
5
+ *
6
+ * On a video call the avatar worker is an agent participant too, but it joins
7
+ * on the agent's behalf and never answers RPCs, so the first one found is the
8
+ * one to talk to. */
9
+ export function talqingAgentParticipant(room: Room): RemoteParticipant | undefined {
10
+ return [...room.remoteParticipants.values()].find(
11
+ (participant: RemoteParticipant) => participant.kind === ParticipantKind.AGENT,
12
+ );
13
+ }
14
+
15
+ /** Merge a patch into the live session's userdata, over RPC to the agent.
16
+ *
17
+ * Resolves rather than throws when the agent is not there: a write that did
18
+ * not land is a state a page can render. */
19
+ export async function setTalqingUserdata(
20
+ room: Room,
21
+ patch: JsonObject,
22
+ responseTimeout = 3,
23
+ ): Promise<{ ok: boolean; error?: string }> {
24
+ const agent = talqingAgentParticipant(room);
25
+ if (!agent) return { ok: false, error: "agent participant is not connected" };
26
+ const response = await room.localParticipant.performRpc({
27
+ destinationIdentity: agent.identity,
28
+ method: "talqing.client.userdata_set",
29
+ payload: JSON.stringify(patch),
30
+ responseTimeout,
31
+ });
32
+ return JSON.parse(response || "{}") as { ok: boolean; error?: string };
33
+ }
34
+
35
+ /** Read the live session's userdata, or only the keys named.
36
+ *
37
+ * Throws when the agent is not there, unlike `setTalqingUserdata`: a read that
38
+ * returned nothing is not a state anything can render. */
39
+ export async function getTalqingUserdata(
40
+ room: Room,
41
+ keys?: string[],
42
+ responseTimeout = 3,
43
+ ): Promise<JsonObject> {
44
+ const agent = talqingAgentParticipant(room);
45
+ if (!agent) throw new Error("agent participant is not connected");
46
+ const response = await room.localParticipant.performRpc({
47
+ destinationIdentity: agent.identity,
48
+ method: "talqing.client.userdata_get",
49
+ payload: JSON.stringify(keys ? { keys } : {}),
50
+ responseTimeout,
51
+ });
52
+ const parsed = JSON.parse(response || "{}") as JsonObject & { ok?: boolean; error?: string };
53
+ if (parsed.ok === false) {
54
+ throw new Error(String(parsed.error || "userdata RPC failed"));
55
+ }
56
+ return parsed;
57
+ }