@alexkroman1/aai-ui 0.9.3 → 0.10.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 (39) hide show
  1. package/dist/_components/app.d.ts +19 -1
  2. package/dist/_components/app.js +19 -1
  3. package/dist/_components/button.d.ts +43 -4
  4. package/dist/_components/button.js +33 -9
  5. package/dist/_components/chat-view.d.ts +25 -1
  6. package/dist/_components/chat-view.js +25 -1
  7. package/dist/_components/controls.d.ts +15 -1
  8. package/dist/_components/controls.js +15 -1
  9. package/dist/_components/error-banner.d.ts +15 -1
  10. package/dist/_components/error-banner.js +15 -1
  11. package/dist/_components/message-bubble.d.ts +19 -3
  12. package/dist/_components/message-bubble.js +17 -1
  13. package/dist/_components/message-list.d.ts +18 -1
  14. package/dist/_components/message-list.js +20 -3
  15. package/dist/_components/state-indicator.d.ts +23 -1
  16. package/dist/_components/state-indicator.js +23 -1
  17. package/dist/_components/thinking-indicator.d.ts +12 -1
  18. package/dist/_components/thinking-indicator.js +23 -11
  19. package/dist/_components/tool-call-block.d.ts +21 -1
  20. package/dist/_components/tool-call-block.js +33 -11
  21. package/dist/_components/transcript.d.ts +19 -1
  22. package/dist/_components/transcript.js +19 -1
  23. package/dist/_sqlite-stub.d.ts +3 -0
  24. package/dist/audio.js +13 -3
  25. package/dist/client-handler.d.ts +43 -0
  26. package/dist/index.d.ts +23 -6
  27. package/dist/index.js +3 -4
  28. package/dist/mount.d.ts +4 -0
  29. package/dist/mount.js +7 -4
  30. package/dist/session-D0RdWlWH.js +451 -0
  31. package/dist/session.d.ts +5 -36
  32. package/dist/session.js +1 -358
  33. package/dist/signals.d.ts +48 -0
  34. package/dist/signals.js +84 -25
  35. package/dist/types.d.ts +19 -5
  36. package/package.json +12 -10
  37. package/styles.css +2 -3
  38. package/dist/components.d.ts +0 -31
  39. package/dist/components.js +0 -17
@@ -0,0 +1,451 @@
1
+ import { errorMessage } from "@alexkroman1/aai/utils";
2
+ import { ReadyConfigSchema, ServerMessageSchema } from "@alexkroman1/aai/protocol";
3
+ //#region client-handler.ts
4
+ /**
5
+ * Handles server→client messages and updates reactive Preact signals
6
+ * accordingly (state transitions, transcripts, messages, audio playback).
7
+ *
8
+ * @internal Exported for testing only.
9
+ */
10
+ var ClientHandler = class {
11
+ #state;
12
+ #messages;
13
+ #toolCalls;
14
+ #userUtterance;
15
+ #agentUtterance;
16
+ #error;
17
+ #voiceIO;
18
+ #batch;
19
+ /** Incremented on each turn boundary — stale async callbacks compare against this. */
20
+ #generation = 0;
21
+ /** Accumulated chat_delta text for real-time display. */
22
+ #deltaAccum = "";
23
+ constructor(opts) {
24
+ this.#state = opts.state;
25
+ this.#messages = opts.messages;
26
+ this.#toolCalls = opts.toolCalls;
27
+ this.#userUtterance = opts.userUtterance;
28
+ this.#agentUtterance = opts.agentUtterance;
29
+ this.#error = opts.error;
30
+ this.#voiceIO = opts.voiceIO;
31
+ this.#batch = opts.batch;
32
+ }
33
+ /** Single entry point for all server→client session events. */
34
+ event(e) {
35
+ switch (e.type) {
36
+ case "speech_started":
37
+ this.#userUtterance.value = "";
38
+ break;
39
+ case "speech_stopped": break;
40
+ case "transcript":
41
+ this.#userUtterance.value = e.text;
42
+ break;
43
+ case "turn":
44
+ this.#generation++;
45
+ this.#deltaAccum = "";
46
+ this.#batch(() => {
47
+ this.#userUtterance.value = null;
48
+ this.#messages.value = [...this.#messages.value, {
49
+ role: "user",
50
+ content: e.text
51
+ }];
52
+ this.#state.value = "thinking";
53
+ });
54
+ break;
55
+ case "chat_delta":
56
+ this.#deltaAccum += (this.#deltaAccum ? " " : "") + e.text;
57
+ this.#agentUtterance.value = this.#deltaAccum;
58
+ break;
59
+ case "chat":
60
+ this.#deltaAccum = "";
61
+ this.#batch(() => {
62
+ this.#agentUtterance.value = null;
63
+ this.#messages.value = [...this.#messages.value, {
64
+ role: "assistant",
65
+ content: e.text
66
+ }];
67
+ });
68
+ break;
69
+ case "tool_call_start":
70
+ this.#toolCalls.value = [...this.#toolCalls.value, {
71
+ toolCallId: e.toolCallId,
72
+ toolName: e.toolName,
73
+ args: e.args,
74
+ status: "pending",
75
+ updates: [],
76
+ afterMessageIndex: this.#messages.value.length - 1
77
+ }];
78
+ break;
79
+ case "tool_call_update": {
80
+ const tcs = this.#toolCalls.value;
81
+ const idx = tcs.findIndex((tc) => tc.toolCallId === e.toolCallId);
82
+ if (idx !== -1) {
83
+ const updated = [...tcs];
84
+ const existing = updated[idx];
85
+ if (existing) updated[idx] = {
86
+ ...existing,
87
+ updates: [...existing.updates, e.data]
88
+ };
89
+ this.#toolCalls.value = updated;
90
+ }
91
+ break;
92
+ }
93
+ case "tool_call_done": {
94
+ const tcs = this.#toolCalls.value;
95
+ const idx = tcs.findIndex((tc) => tc.toolCallId === e.toolCallId);
96
+ if (idx !== -1) {
97
+ const updated = [...tcs];
98
+ const existing = updated[idx];
99
+ if (existing) updated[idx] = {
100
+ ...existing,
101
+ status: "done",
102
+ result: e.result
103
+ };
104
+ this.#toolCalls.value = updated;
105
+ }
106
+ break;
107
+ }
108
+ case "tts_done":
109
+ this.#state.value = "listening";
110
+ break;
111
+ case "cancelled":
112
+ this.#generation++;
113
+ this.#voiceIO()?.flush();
114
+ this.#batch(() => {
115
+ this.#userUtterance.value = null;
116
+ this.#agentUtterance.value = null;
117
+ this.#state.value = "listening";
118
+ });
119
+ break;
120
+ case "reset":
121
+ this.#generation++;
122
+ this.#voiceIO()?.flush();
123
+ this.#batch(() => {
124
+ this.#messages.value = [];
125
+ this.#toolCalls.value = [];
126
+ this.#userUtterance.value = null;
127
+ this.#agentUtterance.value = null;
128
+ this.#error.value = null;
129
+ this.#state.value = "listening";
130
+ });
131
+ break;
132
+ case "error":
133
+ console.error("Agent error:", e.message);
134
+ this.#batch(() => {
135
+ this.#error.value = {
136
+ code: e.code,
137
+ message: e.message
138
+ };
139
+ this.#state.value = "error";
140
+ });
141
+ break;
142
+ default: break;
143
+ }
144
+ }
145
+ /** Enqueue a PCM16 audio chunk for playback. Transitions state to `"speaking"` on the first chunk. */
146
+ playAudioChunk(chunk) {
147
+ if (this.#state.value === "error") return;
148
+ if (this.#state.value !== "speaking") this.#state.value = "speaking";
149
+ if (chunk.buffer instanceof ArrayBuffer) this.#voiceIO()?.enqueue(chunk.buffer);
150
+ }
151
+ /**
152
+ * Signal that the server has finished sending audio for this turn.
153
+ * Waits for the audio queue to drain, then transitions state to `"listening"`.
154
+ * Uses the `#generation` counter to discard stale completions from interrupted turns.
155
+ */
156
+ playAudioDone() {
157
+ const gen = this.#generation;
158
+ const io = this.#voiceIO();
159
+ if (io) io.done().then(() => {
160
+ if (this.#generation !== gen) return;
161
+ this.#state.value = "listening";
162
+ }).catch((err) => {
163
+ console.warn("Audio playback done failed:", err);
164
+ });
165
+ else this.#state.value = "listening";
166
+ }
167
+ /**
168
+ * Dispatch an incoming WebSocket message (text or binary).
169
+ *
170
+ * Returns the parsed config if the message is a `config` message,
171
+ * otherwise `null`.
172
+ */
173
+ handleMessage(data) {
174
+ if (data instanceof ArrayBuffer) {
175
+ this.playAudioChunk(new Uint8Array(data));
176
+ return null;
177
+ }
178
+ let msg;
179
+ try {
180
+ const parsed = ServerMessageSchema.safeParse(JSON.parse(data));
181
+ if (!parsed.success) {
182
+ console.warn("Ignoring invalid server message:", parsed.error.message);
183
+ return null;
184
+ }
185
+ msg = parsed.data;
186
+ } catch {
187
+ return null;
188
+ }
189
+ if (msg.type === "config") {
190
+ const { type: _, sessionId, ...config } = msg;
191
+ const parsed = ReadyConfigSchema.safeParse(config);
192
+ if (!parsed.success) {
193
+ console.warn("Unsupported server config:", parsed.error.message);
194
+ return null;
195
+ }
196
+ return sessionId ? {
197
+ ...parsed.data,
198
+ sessionId
199
+ } : parsed.data;
200
+ }
201
+ if (msg.type === "audio_done") {
202
+ this.playAudioDone();
203
+ return null;
204
+ }
205
+ this.event(msg);
206
+ return null;
207
+ }
208
+ };
209
+ //#endregion
210
+ //#region session.ts
211
+ /** Built-in non-reactive container (plain mutable wrapper). */
212
+ function plainReactive(initial) {
213
+ return { value: initial };
214
+ }
215
+ /** No-op batch — just calls the function. */
216
+ function plainBatch(fn) {
217
+ fn();
218
+ }
219
+ /**
220
+ * Initialize audio capture and playback after the server sends a ready config.
221
+ *
222
+ * Lifecycle: dynamically import audio modules → request microphone access →
223
+ * register AudioWorklet processors → create a `VoiceIO` instance → send
224
+ * `audio_ready` to the server → transition state to `"listening"`.
225
+ *
226
+ * Uses the connection `generation` counter to detect if `connect()` was called
227
+ * while awaiting async operations; if so, the stale VoiceIO is closed immediately
228
+ * to prevent it from being assigned to a newer connection.
229
+ *
230
+ * On failure (e.g. microphone permission denied, WebSocket closed mid-setup),
231
+ * sets the error state and transitions to `"error"`.
232
+ *
233
+ * @param conn - The shared mutable connection state (WebSocket, VoiceIO, generation).
234
+ * @param msg - The `ReadyConfig` from the server containing audio sample rates.
235
+ * @param deps.send - Send a typed client message over the WebSocket.
236
+ * @param deps.sendBinary - Send raw binary audio data over the WebSocket.
237
+ * @param deps.state - Reactive state signal for the agent's current state.
238
+ * @param deps.error - Reactive signal for session errors.
239
+ * @param deps.batch - Batching function for grouping reactive updates.
240
+ */
241
+ async function initAudioCapture(conn, msg, deps) {
242
+ if (conn.audioSetupInFlight) return;
243
+ conn.audioSetupInFlight = true;
244
+ const gen = conn.generation;
245
+ try {
246
+ const [{ createVoiceIO }, captureWorklet, playbackWorklet] = await Promise.all([
247
+ import("./audio.js"),
248
+ import("./worklets/capture-processor.js").then((m) => m.default),
249
+ import("./worklets/playback-processor.js").then((m) => m.default)
250
+ ]);
251
+ const io = await createVoiceIO({
252
+ sttSampleRate: msg.sampleRate,
253
+ ttsSampleRate: msg.ttsSampleRate,
254
+ captureWorkletSrc: captureWorklet,
255
+ playbackWorkletSrc: playbackWorklet,
256
+ onMicData: (pcm16) => {
257
+ try {
258
+ deps.sendBinary(pcm16);
259
+ } catch {
260
+ console.debug("[aai-ui] sendBinary dropped: connection closed");
261
+ }
262
+ }
263
+ });
264
+ if (conn.generation !== gen || !conn.ws || conn.ws.readyState !== WebSocket.OPEN) {
265
+ io.close();
266
+ return;
267
+ }
268
+ conn.voiceIO = io;
269
+ deps.send({ type: "audio_ready" });
270
+ deps.state.value = "listening";
271
+ } catch (err) {
272
+ if (conn.generation !== gen || !conn.ws || conn.ws.readyState !== WebSocket.OPEN) return;
273
+ deps.batch(() => {
274
+ deps.error.value = {
275
+ code: "audio",
276
+ message: `Microphone access failed: ${errorMessage(err)}`
277
+ };
278
+ deps.state.value = "error";
279
+ });
280
+ } finally {
281
+ conn.audioSetupInFlight = false;
282
+ }
283
+ }
284
+ function buildWsUrl(platformUrl, resume, sessionId) {
285
+ const wsUrl = new URL("websocket", platformUrl.endsWith("/") ? platformUrl : `${platformUrl}/`);
286
+ wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
287
+ if (sessionId) wsUrl.searchParams.set("sessionId", sessionId);
288
+ else if (resume) wsUrl.searchParams.set("resume", "1");
289
+ return wsUrl;
290
+ }
291
+ /**
292
+ * Create a voice session that connects to an AAI server via WebSocket.
293
+ *
294
+ * Uses plain JSON text frames and binary audio frames for communication.
295
+ *
296
+ * @param options - Session configuration including the platform server URL.
297
+ * @returns A {@link VoiceSession} handle for controlling the session.
298
+ *
299
+ * @public
300
+ */
301
+ function createVoiceSession(options) {
302
+ const reactive = options.reactiveFactory ?? plainReactive;
303
+ const batchFn = options.batch ?? plainBatch;
304
+ const state = reactive("disconnected");
305
+ const messages = reactive([]);
306
+ const toolCalls = reactive([]);
307
+ const userUtterance = reactive(null);
308
+ const agentUtterance = reactive(null);
309
+ const error = reactive(null);
310
+ const disconnected = reactive(null);
311
+ const conn = {
312
+ ws: null,
313
+ voiceIO: null,
314
+ audioSetupInFlight: false,
315
+ generation: 0
316
+ };
317
+ let connectionController = null;
318
+ let hasConnected = false;
319
+ function cleanupAudio() {
320
+ conn.audioSetupInFlight = false;
321
+ conn.voiceIO?.close();
322
+ conn.voiceIO = null;
323
+ }
324
+ function resetState() {
325
+ batchFn(() => {
326
+ messages.value = [];
327
+ toolCalls.value = [];
328
+ userUtterance.value = null;
329
+ agentUtterance.value = null;
330
+ error.value = null;
331
+ });
332
+ }
333
+ function send(msg) {
334
+ if (conn.ws && conn.ws.readyState === WebSocket.OPEN) conn.ws.send(JSON.stringify(msg));
335
+ }
336
+ function sendBinary(data) {
337
+ if (conn.ws && conn.ws.readyState === WebSocket.OPEN) conn.ws.send(data);
338
+ }
339
+ const audioDeps = {
340
+ send,
341
+ sendBinary,
342
+ state,
343
+ error,
344
+ batch: batchFn
345
+ };
346
+ function connect(opts) {
347
+ disconnected.value = null;
348
+ state.value = "connecting";
349
+ connectionController?.abort();
350
+ cleanupAudio();
351
+ conn.ws?.close();
352
+ conn.ws = null;
353
+ conn.generation++;
354
+ const controller = new AbortController();
355
+ connectionController = controller;
356
+ const { signal: sig } = controller;
357
+ if (opts?.signal) opts.signal.addEventListener("abort", () => disconnect(), { signal: sig });
358
+ const resumeId = !hasConnected ? options.resumeSessionId : void 0;
359
+ const wsUrl = buildWsUrl(options.platformUrl, hasConnected, resumeId);
360
+ const socket = new WebSocket(wsUrl.toString());
361
+ socket.binaryType = "arraybuffer";
362
+ conn.ws = socket;
363
+ const handler = new ClientHandler({
364
+ state,
365
+ messages,
366
+ toolCalls,
367
+ userUtterance,
368
+ agentUtterance,
369
+ error,
370
+ voiceIO: () => conn.voiceIO,
371
+ batch: batchFn
372
+ });
373
+ socket.addEventListener("open", () => {
374
+ state.value = "ready";
375
+ }, { signal: sig });
376
+ socket.addEventListener("message", (event) => {
377
+ const config = handler.handleMessage(event.data);
378
+ if (config) {
379
+ if (config.sessionId) options.onSessionId?.(config.sessionId);
380
+ const isReconnect = hasConnected;
381
+ hasConnected = true;
382
+ initAudioCapture(conn, config, audioDeps).catch((err) => {
383
+ audioDeps.batch(() => {
384
+ audioDeps.error.value = {
385
+ code: "audio",
386
+ message: `Audio capture failed: ${errorMessage(err)}`
387
+ };
388
+ audioDeps.state.value = "error";
389
+ });
390
+ });
391
+ if (isReconnect && messages.value.length > 0) send({
392
+ type: "history",
393
+ messages: messages.value.map((m) => ({
394
+ role: m.role,
395
+ content: m.content
396
+ }))
397
+ });
398
+ }
399
+ }, { signal: sig });
400
+ socket.addEventListener("close", () => {
401
+ if (sig.aborted) return;
402
+ controller.abort();
403
+ disconnected.value = { intentional: false };
404
+ cleanupAudio();
405
+ state.value = "disconnected";
406
+ }, { signal: sig });
407
+ }
408
+ function cancel() {
409
+ conn.voiceIO?.flush();
410
+ state.value = "listening";
411
+ send({ type: "cancel" });
412
+ }
413
+ function reset() {
414
+ conn.voiceIO?.flush();
415
+ if (conn.ws && conn.ws.readyState === WebSocket.OPEN) {
416
+ send({ type: "reset" });
417
+ return;
418
+ }
419
+ resetState();
420
+ disconnect();
421
+ connect();
422
+ }
423
+ function disconnect() {
424
+ connectionController?.abort();
425
+ connectionController = null;
426
+ cleanupAudio();
427
+ conn.ws?.close();
428
+ conn.ws = null;
429
+ state.value = "disconnected";
430
+ disconnected.value = { intentional: true };
431
+ }
432
+ return {
433
+ state,
434
+ messages,
435
+ toolCalls,
436
+ userUtterance,
437
+ agentUtterance,
438
+ error,
439
+ disconnected,
440
+ connect,
441
+ cancel,
442
+ resetState,
443
+ reset,
444
+ disconnect,
445
+ [Symbol.dispose]() {
446
+ disconnect();
447
+ }
448
+ };
449
+ }
450
+ //#endregion
451
+ export { ClientHandler as n, createVoiceSession as t };
package/dist/session.d.ts CHANGED
@@ -1,8 +1,6 @@
1
- import type { ClientEvent, ReadyConfig } from "@alexkroman1/aai/protocol";
2
- import type { VoiceIO } from "./audio.ts";
3
- import type { AgentState, Message, Reactive, SessionError, SessionErrorCode, SessionOptions, ToolCallInfo } from "./types.ts";
4
- export type { AgentState, Message, Reactive, SessionError, SessionErrorCode, SessionOptions, ToolCallInfo, };
5
- type BatchFn = (fn: () => void) => void;
1
+ import type { AgentState, ChatMessage, Reactive, SessionError, ToolCallInfo, VoiceSessionOptions } from "./types.ts";
2
+ export { ClientHandler } from "./client-handler.ts";
3
+ export type { AgentState, ChatMessage, Reactive, SessionError, SessionErrorCode, ToolCallInfo, VoiceSessionOptions, } from "./types.ts";
6
4
  /**
7
5
  * A reactive voice session that manages WebSocket communication,
8
6
  * audio capture/playback, and agent state transitions.
@@ -18,7 +16,7 @@ export type VoiceSession = {
18
16
  /** Current agent state (connecting, listening, thinking, etc.). */
19
17
  readonly state: Reactive<AgentState>;
20
18
  /** Chat message history for the session. */
21
- readonly messages: Reactive<Message[]>;
19
+ readonly messages: Reactive<ChatMessage[]>;
22
20
  /** Active tool calls for the current turn. */
23
21
  readonly toolCalls: Reactive<ToolCallInfo[]>;
24
22
  /**
@@ -58,35 +56,6 @@ export type VoiceSession = {
58
56
  /** Alias for `disconnect` for use with `using`. */
59
57
  [Symbol.dispose](): void;
60
58
  };
61
- /**
62
- * Handles server→client messages and updates reactive Preact signals
63
- * accordingly (state transitions, transcripts, messages, audio playback).
64
- */
65
- /** @internal Exported for testing only. */
66
- export declare class ClientHandler {
67
- #private;
68
- constructor(opts: {
69
- state: Reactive<AgentState>;
70
- messages: Reactive<Message[]>;
71
- toolCalls: Reactive<ToolCallInfo[]>;
72
- userUtterance: Reactive<string | null>;
73
- agentUtterance: Reactive<string | null>;
74
- error: Reactive<SessionError | null>;
75
- voiceIO: () => VoiceIO | null;
76
- batch: BatchFn;
77
- });
78
- /** Single entry point for all server→client session events. */
79
- event(e: ClientEvent): void;
80
- playAudioChunk(chunk: Uint8Array): void;
81
- playAudioDone(): void;
82
- /**
83
- * Dispatch an incoming WebSocket message (text or binary).
84
- *
85
- * Returns the parsed config if the message is a `config` message,
86
- * otherwise `null`.
87
- */
88
- handleMessage(data: string | ArrayBuffer): ReadyConfig | null;
89
- }
90
59
  /**
91
60
  * Create a voice session that connects to an AAI server via WebSocket.
92
61
  *
@@ -97,4 +66,4 @@ export declare class ClientHandler {
97
66
  *
98
67
  * @public
99
68
  */
100
- export declare function createVoiceSession(options: SessionOptions): VoiceSession;
69
+ export declare function createVoiceSession(options: VoiceSessionOptions): VoiceSession;