@alexkroman1/aai-ui 1.4.4 → 1.5.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.
@@ -6,7 +6,7 @@
6
6
  <title>aai</title>
7
7
  <link rel="icon" href="data:," />
8
8
  <style>html, body { background: #101010; margin: 0; }</style>
9
- <script type="module" crossorigin src="./assets/index-Cx2YvEpb.js"></script>
9
+ <script type="module" crossorigin src="./assets/index-BlMvrUC4.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="./assets/index-BZqVGR2o.css">
11
11
  </head>
12
12
  <body>
@@ -1,5 +1,5 @@
1
1
  import { WS_OPEN, errorMessage } from "@alexkroman1/aai";
2
- import { ReadyConfigSchema, ServerMessageSchema, lenientParse } from "@alexkroman1/aai/protocol";
2
+ import { ServerMessageSchema, lenientParse } from "@alexkroman1/aai/protocol";
3
3
  //#region session-core.ts
4
4
  /**
5
5
  * Framework-agnostic voice session core.
@@ -10,6 +10,14 @@ import { ReadyConfigSchema, ServerMessageSchema, lenientParse } from "@alexkroma
10
10
  *
11
11
  * No dependency on React, Preact, or any UI framework.
12
12
  */
13
+ /** Cap on `customEvents` retained in the session snapshot to avoid unbounded growth. */
14
+ const MAX_CUSTOM_EVENTS = 200;
15
+ /** Cap on `messages` retained in the session snapshot; mirrors host-side DEFAULT_MAX_HISTORY. */
16
+ const MAX_MESSAGES = 200;
17
+ function appendCapped(list, item, cap) {
18
+ const next = [...list, item];
19
+ return next.length > cap ? next.slice(-cap) : next;
20
+ }
13
21
  /**
14
22
  * Initialize audio capture and playback after the server sends a ready config.
15
23
  *
@@ -41,9 +49,9 @@ async function initAudioCapture(conn, msg, deps) {
41
49
  playbackWorkletSrc: playbackWorklet,
42
50
  onMicData: (pcm16) => {
43
51
  try {
44
- deps.sendBinary(pcm16);
52
+ deps.sendAudio(new Uint8Array(pcm16));
45
53
  } catch {
46
- console.debug("[aai-ui] sendBinary dropped: connection closed");
54
+ console.debug("[aai-ui] sendAudio dropped: connection closed");
47
55
  }
48
56
  }
49
57
  });
@@ -52,7 +60,7 @@ async function initAudioCapture(conn, msg, deps) {
52
60
  return;
53
61
  }
54
62
  conn.voiceIO = io;
55
- deps.send({ type: "audio_ready" });
63
+ deps.sendJson({ type: "audio_ready" });
56
64
  deps.updateState({ state: "listening" });
57
65
  } catch (err) {
58
66
  if (conn.generation !== gen || !conn.ws || conn.ws.readyState !== WS_OPEN) return;
@@ -143,39 +151,46 @@ function createSessionCore(options) {
143
151
  error: null
144
152
  });
145
153
  }
146
- function send(msg) {
154
+ function sendJson(msg) {
147
155
  if (conn.ws && conn.ws.readyState === WS_OPEN) conn.ws.send(JSON.stringify(msg));
148
156
  }
149
- function sendBinary(data) {
150
- if (conn.ws && conn.ws.readyState === WS_OPEN) conn.ws.send(data);
157
+ function sendAudio(bytes) {
158
+ if (conn.ws && conn.ws.readyState === WS_OPEN) conn.ws.send(bytes);
151
159
  }
152
160
  const audioDeps = {
153
- send,
154
- sendBinary,
161
+ sendJson,
162
+ sendAudio,
155
163
  updateState
156
164
  };
157
165
  /** Incremented on each turn boundary -- stale async callbacks compare against this. */
158
166
  let handlerGeneration = 0;
159
167
  /** Monotonically increasing counter for custom events -- used by useEvent to deduplicate. */
160
168
  let customEventSeq = 0;
169
+ function appendCustomEvent(name, data) {
170
+ updateState({ customEvents: appendCapped(currentSnapshot.customEvents, {
171
+ id: ++customEventSeq,
172
+ event: name,
173
+ data
174
+ }, MAX_CUSTOM_EVENTS) });
175
+ }
161
176
  function handleUserTranscriptEvent(text) {
162
177
  handlerGeneration++;
163
178
  updateState({
164
179
  userTranscript: null,
165
- messages: [...currentSnapshot.messages, {
180
+ messages: appendCapped(currentSnapshot.messages, {
166
181
  role: "user",
167
182
  content: text
168
- }],
183
+ }, MAX_MESSAGES),
169
184
  state: "thinking"
170
185
  });
171
186
  }
172
187
  function handleAgentTranscriptEvent(text) {
173
188
  updateState({
174
189
  agentTranscript: null,
175
- messages: [...currentSnapshot.messages, {
190
+ messages: appendCapped(currentSnapshot.messages, {
176
191
  role: "assistant",
177
192
  content: text
178
- }]
193
+ }, MAX_MESSAGES)
179
194
  });
180
195
  }
181
196
  /** Single entry point for all server->client session events. */
@@ -199,7 +214,7 @@ function createSessionCore(options) {
199
214
  updateState({ toolCalls: [...currentSnapshot.toolCalls, {
200
215
  callId: e.toolCallId,
201
216
  name: e.toolName,
202
- args: e.args,
217
+ args: e.args ?? {},
203
218
  status: "pending",
204
219
  afterMessageIndex: currentSnapshot.messages.length - 1
205
220
  }] });
@@ -245,11 +260,7 @@ function createSessionCore(options) {
245
260
  });
246
261
  break;
247
262
  case "custom_event":
248
- updateState({ customEvents: [...currentSnapshot.customEvents, {
249
- id: ++customEventSeq,
250
- event: e.event,
251
- data: e.data
252
- }] });
263
+ appendCustomEvent(e.event, e.data);
253
264
  break;
254
265
  case "error":
255
266
  console.error("Agent error:", e.message);
@@ -262,6 +273,7 @@ function createSessionCore(options) {
262
273
  running: false
263
274
  });
264
275
  break;
276
+ case "idle_timeout": break;
265
277
  default: break;
266
278
  }
267
279
  }
@@ -269,7 +281,7 @@ function createSessionCore(options) {
269
281
  function playAudioChunk(chunk) {
270
282
  if (currentSnapshot.state === "disconnected" && currentSnapshot.error !== null) return;
271
283
  if (currentSnapshot.state !== "speaking") updateState({ state: "speaking" });
272
- if (chunk.buffer instanceof ArrayBuffer) conn.voiceIO?.enqueue(chunk.buffer);
284
+ conn.voiceIO?.enqueue(chunk.buffer);
273
285
  }
274
286
  /**
275
287
  * Signal that the server has finished sending audio for this turn.
@@ -287,52 +299,47 @@ function createSessionCore(options) {
287
299
  });
288
300
  else updateState({ state: "listening" });
289
301
  }
290
- /** Parse a JSON text frame into a ServerMessage, or null if invalid/unknown. */
291
- function parseTextFrame(data) {
292
- try {
293
- const result = lenientParse(ServerMessageSchema, JSON.parse(data));
294
- if (!result.ok) {
295
- if (result.malformed) console.warn("Ignoring invalid server message:", result.error);
296
- return null;
297
- }
298
- return result.data;
299
- } catch {
300
- return null;
301
- }
302
- }
303
- /** Try to extract a ReadyConfig from a config message, or null. */
304
- function parseConfig(msg) {
305
- const { type: _, sessionId, ...config } = msg;
306
- const parsed = ReadyConfigSchema.safeParse(config);
307
- if (!parsed.success) {
308
- console.warn("Unsupported server config:", parsed.error.message);
309
- return null;
310
- }
311
- return sessionId ? {
312
- ...parsed.data,
313
- sessionId
314
- } : parsed.data;
315
- }
316
302
  /**
317
- * Dispatch an incoming WebSocket message (text or binary).
303
+ * Dispatch an incoming WebSocket message.
304
+ *
305
+ * Binary frames carry raw PCM16 audio chunks. Text frames are JSON-encoded
306
+ * {@link ServerMessage} values validated via Zod.
318
307
  *
319
308
  * Returns the parsed config if the message is a `config` message,
320
- * otherwise `null`.
309
+ * otherwise `undefined`.
321
310
  */
322
311
  function handleMessage(data) {
323
312
  if (data instanceof ArrayBuffer) {
324
313
  playAudioChunk(new Uint8Array(data));
325
- return null;
314
+ return;
326
315
  }
327
- const msg = parseTextFrame(data);
328
- if (!msg) return null;
329
- if (msg.type === "config") return parseConfig(msg);
316
+ if (typeof data !== "string") {
317
+ console.warn("session-core: non-string, non-binary frame received; dropping");
318
+ return;
319
+ }
320
+ let raw;
321
+ try {
322
+ raw = JSON.parse(data);
323
+ } catch {
324
+ console.warn("session-core: invalid JSON; dropping");
325
+ return;
326
+ }
327
+ const parsed = lenientParse(ServerMessageSchema, raw);
328
+ if (!parsed.ok) {
329
+ if (parsed.malformed) console.warn("session-core: malformed server message", parsed.error);
330
+ return;
331
+ }
332
+ const msg = parsed.data;
333
+ if (msg.type === "config") return {
334
+ sampleRate: msg.sampleRate,
335
+ ttsSampleRate: msg.ttsSampleRate,
336
+ sid: msg.sessionId
337
+ };
330
338
  if (msg.type === "audio_done") {
331
339
  playAudioDone();
332
- return null;
340
+ return;
333
341
  }
334
342
  handleEvent(msg);
335
- return null;
336
343
  }
337
344
  function connect(opts) {
338
345
  updateState({
@@ -358,7 +365,7 @@ function createSessionCore(options) {
358
365
  socket.addEventListener("message", (event) => {
359
366
  const config = handleMessage(event.data);
360
367
  if (config) {
361
- if (config.sessionId) options.onSessionId?.(config.sessionId);
368
+ if (config.sid) options.onSessionId?.(config.sid);
362
369
  const isReconnect = hasConnected;
363
370
  hasConnected = true;
364
371
  initAudioCapture(conn, config, audioDeps).catch((err) => {
@@ -371,7 +378,7 @@ function createSessionCore(options) {
371
378
  running: false
372
379
  });
373
380
  });
374
- if (isReconnect && currentSnapshot.messages.length > 0) send({
381
+ if (isReconnect && currentSnapshot.messages.length > 0) sendJson({
375
382
  type: "history",
376
383
  messages: currentSnapshot.messages.map((m) => ({
377
384
  role: m.role,
@@ -393,12 +400,12 @@ function createSessionCore(options) {
393
400
  function cancel() {
394
401
  conn.voiceIO?.flush();
395
402
  updateState({ state: "listening" });
396
- send({ type: "cancel" });
403
+ sendJson({ type: "cancel" });
397
404
  }
398
405
  function reset() {
399
406
  conn.voiceIO?.flush();
400
407
  if (conn.ws && conn.ws.readyState === WS_OPEN) {
401
- send({ type: "reset" });
408
+ sendJson({ type: "reset" });
402
409
  return;
403
410
  }
404
411
  resetState();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai-ui",
3
- "version": "1.4.4",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "clsx": "^2.1.1",
21
- "@alexkroman1/aai": "1.4.4"
21
+ "@alexkroman1/aai": "1.5.0"
22
22
  },
23
23
  "peerDependencies": {
24
24
  "react": "^19.0.0",