@alexkroman1/aai-ui 5.14.0 → 6.2.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.
- package/README.md +2 -1
- package/dist/_repeat-until.d.ts +30 -0
- package/dist/_sse.d.ts +56 -0
- package/dist/_workflow-api-ref.d.ts +37 -0
- package/dist/audio.js +26 -26
- package/dist/{chat-view-CgFytvGy.js → chat-view-CK61bWWx.js} +2 -1
- package/dist/components/_form-values.d.ts +19 -0
- package/dist/components/chat-view.js +1 -1
- package/dist/components/form-types.d.ts +67 -0
- package/dist/components/form.d.ts +138 -0
- package/dist/components/message-list.js +1 -1
- package/dist/components/workflow-fields.d.ts +57 -0
- package/dist/components/workflow-progress.d.ts +55 -0
- package/dist/default-client/assets/audio-fO7SVU64.js +1 -0
- package/dist/default-client/assets/{capture-processor-B_5Ive8e.js → capture-processor-Dmc-KEpb.js} +4 -4
- package/dist/default-client/assets/client-audio-constants-Ck0IJO4c.js +1 -0
- package/dist/default-client/assets/index-CDugAuLK.css +2 -0
- package/dist/default-client/assets/index-DCI51Xz_.js +293 -0
- package/dist/default-client/assets/{playback-processor-6L8SIQ_l.js → playback-processor-DwQ9tE7X.js} +16 -13
- package/dist/default-client/index.html +3 -2
- package/dist/define-client.d.ts +40 -1
- package/dist/define-client.js +59 -17
- package/dist/index.d.ts +10 -0
- package/dist/index.js +1595 -5
- package/dist/{message-list-CcjgWRVZ.js → message-list-BwA3rdPi.js} +15 -1
- package/dist/page.d.ts +88 -0
- package/dist/{session-core-BA8H3qtF.js → session-core-ClKdVgRU.js} +245 -112
- package/dist/session-core-dial.d.ts +38 -0
- package/dist/session-core-handshake.d.ts +16 -1
- package/dist/session-core-messages.d.ts +2 -2
- package/dist/session-core-reconnect.d.ts +2 -7
- package/dist/session-core.js +1 -1
- package/dist/session-resume-store.d.ts +43 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.js +2 -2
- package/dist/use-user-transcript.d.ts +70 -0
- package/dist/use-workflow-form.d.ts +136 -0
- package/dist/use-workflow-progress.d.ts +100 -0
- package/dist/use-workflow-run.d.ts +56 -0
- package/dist/use-workflow-runs.d.ts +71 -0
- package/dist/workflow-client.d.ts +97 -0
- package/dist/workflow-events.d.ts +39 -0
- package/dist/worklets/_playback-bench-harness.d.ts +181 -0
- package/dist/worklets/_playback-bench-host.d.ts +63 -0
- package/dist/worklets/_playback-bench-page.d.ts +65 -0
- package/dist/worklets/_tts-trace-harness.d.ts +142 -0
- package/dist/worklets/_worklet-test-utils.d.ts +27 -0
- package/dist/worklets/playback-processor.d.ts +1 -1
- package/dist/worklets/playback-processor.js +15 -12
- package/package.json +9 -8
- package/dist/default-client/assets/audio-CsQVQn3f.js +0 -1
- package/dist/default-client/assets/index-D35_z2WM.js +0 -293
- package/dist/default-client/assets/index-DCjB3qtb.css +0 -2
|
@@ -261,11 +261,25 @@ const DOT_STYLES = [
|
|
|
261
261
|
animation: "aai-bounce 1.4s infinite ease-in-out both",
|
|
262
262
|
animationDelay: `${delay}s`
|
|
263
263
|
}));
|
|
264
|
-
/**
|
|
264
|
+
/**
|
|
265
|
+
* Animated three-dot "thinking" indicator.
|
|
266
|
+
*
|
|
267
|
+
* `role="status"` with a label, for the same reason `ConsoleShell` announces
|
|
268
|
+
* its error banner: three animated dots are the only signal that the agent is
|
|
269
|
+
* working on a reply, and to a screen reader they are three empty `<div>`s.
|
|
270
|
+
* It is also the indicator's semantic handle — a spec asserting its presence by
|
|
271
|
+
* counting `.rounded-full` elements breaks when the three dots become a spinner
|
|
272
|
+
* (correct behaviour, red test) and again when any sibling row gains a round
|
|
273
|
+
* badge (wrong behaviour, green test).
|
|
274
|
+
*
|
|
275
|
+
* @internal
|
|
276
|
+
*/
|
|
265
277
|
function ThinkingDots() {
|
|
266
278
|
const theme = useTheme();
|
|
267
279
|
const muted = inkTint(theme.text, theme.surface, 75);
|
|
268
280
|
return /* @__PURE__ */ jsx("div", {
|
|
281
|
+
role: "status",
|
|
282
|
+
"aria-label": "Thinking",
|
|
269
283
|
className: "flex items-center gap-2 text-sm font-medium min-h-5",
|
|
270
284
|
style: { color: muted },
|
|
271
285
|
children: DOT_STYLES.map((style, i) => /* @__PURE__ */ jsx("div", {
|
package/dist/page.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** @jsxImportSource react */
|
|
2
|
+
/**
|
|
3
|
+
* `page()` — mount a WORKFLOW APP's UI: React, theme, no session.
|
|
4
|
+
*
|
|
5
|
+
* The twin of `client()` for an agent whose front door is a form rather than a
|
|
6
|
+
* microphone (`workflowApp()`). It is a separate entry rather than
|
|
7
|
+
* an option on `client()` because of what `client()` unavoidably does: it
|
|
8
|
+
* constructs a `SessionCore`, which owns a WebSocket URL provider, an audio
|
|
9
|
+
* graph, and a microphone request. A flag would have to make all of that
|
|
10
|
+
* conditional, and every session hook would then have to answer "what does this
|
|
11
|
+
* mean with no session?" — so the honest split is two mounts. A page that wants
|
|
12
|
+
* voice uses `client()`; a page that wants neither audio nor a socket uses this.
|
|
13
|
+
*
|
|
14
|
+
* Authoring is otherwise identical — the file is still `client.tsx`, still
|
|
15
|
+
* React, still Tailwind, still the same theme tokens — so a workflow app reads
|
|
16
|
+
* like every other agent. What it reaches for instead of `useSession()` is
|
|
17
|
+
* `createWorkflowApi()` / `useWorkflowRun()`.
|
|
18
|
+
*/
|
|
19
|
+
import { type ComponentType } from "react";
|
|
20
|
+
import type { ClientTheme } from "./types.ts";
|
|
21
|
+
/**
|
|
22
|
+
* Configuration for {@link page}.
|
|
23
|
+
*
|
|
24
|
+
* @public
|
|
25
|
+
*/
|
|
26
|
+
export type PageConfig = {
|
|
27
|
+
/**
|
|
28
|
+
* The root component. Required — a workflow app has no default shell to fall
|
|
29
|
+
* back to, because there is no session for one to render.
|
|
30
|
+
*/
|
|
31
|
+
component: ComponentType;
|
|
32
|
+
/** CSS selector or DOM element to render into. Defaults to `"#app"`. */
|
|
33
|
+
target?: string | HTMLElement;
|
|
34
|
+
/**
|
|
35
|
+
* Page title. Set only when given, so a title the HTML shell declared is never
|
|
36
|
+
* clobbered — the same rule `client()`'s custom-component tier follows.
|
|
37
|
+
*/
|
|
38
|
+
name?: string;
|
|
39
|
+
/** Theme color overrides, read by the same tokens the voice components use. */
|
|
40
|
+
theme?: ClientTheme;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Handle returned by {@link page}. `Disposable`, so `using` works.
|
|
44
|
+
*
|
|
45
|
+
* @public
|
|
46
|
+
*/
|
|
47
|
+
export type PageHandle = {
|
|
48
|
+
/** Unmount the React tree. */
|
|
49
|
+
dispose(): void;
|
|
50
|
+
/** Alias for `dispose` for use with `using`. */
|
|
51
|
+
[Symbol.dispose](): void;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Mount a page for an agent whose work happens in workflows.
|
|
55
|
+
*
|
|
56
|
+
* There is deliberately no session, no microphone, and no socket: the component
|
|
57
|
+
* talks to the agent over the workflow HTTP API
|
|
58
|
+
* (`createWorkflowApi`/`useWorkflowRun`), which is durable and outlives the tab.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```tsx
|
|
62
|
+
* import { createWorkflowApi, page, useWorkflowRun } from "@alexkroman1/aai-ui";
|
|
63
|
+
* import { useState } from "react";
|
|
64
|
+
*
|
|
65
|
+
* // Hoisted: a client built in render is a new object every render.
|
|
66
|
+
* const api = createWorkflowApi();
|
|
67
|
+
*
|
|
68
|
+
* function App() {
|
|
69
|
+
* const [runId, setRunId] = useState<string>();
|
|
70
|
+
* const { run } = useWorkflowRun(runId, { api });
|
|
71
|
+
* return (
|
|
72
|
+
* <button
|
|
73
|
+
* type="button"
|
|
74
|
+
* onClick={() => void api.start("digest", { topic: "ai" }).then(setRunId)}
|
|
75
|
+
* >
|
|
76
|
+
* {run ? run.status : "Start"}
|
|
77
|
+
* </button>
|
|
78
|
+
* );
|
|
79
|
+
* }
|
|
80
|
+
*
|
|
81
|
+
* page({ name: "Digest", component: App });
|
|
82
|
+
* ```
|
|
83
|
+
*
|
|
84
|
+
* @throws If the target element is not found in the DOM.
|
|
85
|
+
*
|
|
86
|
+
* @public
|
|
87
|
+
*/
|
|
88
|
+
export declare function page(config: PageConfig): PageHandle;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { MIC_SEND_MAX_BUFFERED_BYTES } from "./types.js";
|
|
2
2
|
import { CLIENT_CONFIG_PATH, ClientConfigResponseSchema, ServerMessageSchema, lenientParse } from "@alexkroman1/aai/protocol";
|
|
3
|
-
import { DEFAULT_MAX_HISTORY,
|
|
4
|
-
import {
|
|
3
|
+
import { DEFAULT_MAX_HISTORY, errorMessage, safeJsonParse } from "@alexkroman1/aai";
|
|
4
|
+
import { omitUndefined, toArgsRecord } from "@alexkroman1/aai/utils";
|
|
5
|
+
import { WS_OPEN, createEpoch } from "@alexkroman1/aai/internal";
|
|
5
6
|
import ReconnectingWebSocket from "partysocket/ws";
|
|
6
7
|
//#region client-config.ts
|
|
7
8
|
/**
|
|
@@ -252,6 +253,180 @@ function reconnectPending(socket) {
|
|
|
252
253
|
return socket instanceof ReconnectingWebSocket && socket.shouldReconnect && socket.retryCount < RECONNECT_OPTIONS.maxRetries;
|
|
253
254
|
}
|
|
254
255
|
//#endregion
|
|
256
|
+
//#region session-core-url.ts
|
|
257
|
+
/** Build the session WebSocket URL from the platform URL and resume state. */
|
|
258
|
+
function buildWsUrl(platformUrl, resume, sessionId) {
|
|
259
|
+
return applyResumeParams(buildAgentUrl(platformUrl, "websocket"), resume, sessionId);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Turn a broker-provided session URL (`sessionUrl` from `GET client-config`
|
|
263
|
+
* — the agent's live sandbox endpoint) into this attempt's connect URL.
|
|
264
|
+
*/
|
|
265
|
+
function buildBrokeredWsUrl(sessionUrl, resume, sessionId) {
|
|
266
|
+
return applyResumeParams(new URL(sessionUrl), resume, sessionId);
|
|
267
|
+
}
|
|
268
|
+
const WS_PROTOCOLS = {
|
|
269
|
+
"https:": "wss:",
|
|
270
|
+
"http:": "ws:"
|
|
271
|
+
};
|
|
272
|
+
function applyResumeParams(wsUrl, resume, sessionId) {
|
|
273
|
+
wsUrl.protocol = WS_PROTOCOLS[wsUrl.protocol] ?? wsUrl.protocol;
|
|
274
|
+
if (sessionId) wsUrl.searchParams.set("sessionId", sessionId);
|
|
275
|
+
else if (resume) wsUrl.searchParams.set("resume", "1");
|
|
276
|
+
return wsUrl;
|
|
277
|
+
}
|
|
278
|
+
//#endregion
|
|
279
|
+
//#region session-resume-store.ts
|
|
280
|
+
/**
|
|
281
|
+
* Where a session id survives a page RELOAD.
|
|
282
|
+
*
|
|
283
|
+
* The id is what `?sessionId=` presents on reconnect, and it is the key the
|
|
284
|
+
* agent's slot state and event log live under — so a reload that cannot produce
|
|
285
|
+
* it starts a brand-new session, and a UI driven by `useAgentState` comes back
|
|
286
|
+
* empty even though the agent still holds the cart. The server side of the
|
|
287
|
+
* reconstitution was already built (`pushStateSnapshot` force-pushes the
|
|
288
|
+
* projection after hydration on every start, `state.updated` lands in
|
|
289
|
+
* `agentState`); what was missing is that nothing in the browser remembered the
|
|
290
|
+
* id across a reload. `onSessionId`/`resumeSessionId` let a client wire it by
|
|
291
|
+
* hand and exactly one of fourteen templates did, which is the shape of a
|
|
292
|
+
* default in the wrong place.
|
|
293
|
+
*
|
|
294
|
+
* **`sessionStorage`, deliberately, and this is the opposite call from the
|
|
295
|
+
* studio's session token.** A reload and a same-tab navigation survive it; a new
|
|
296
|
+
* tab and a visit tomorrow do not, which is what we want here rather than a
|
|
297
|
+
* limitation: presenting a day-old id suppresses the greeting
|
|
298
|
+
* (`parseWsUpgradeParams` keys that off the id's mere presence) and rejoins a
|
|
299
|
+
* conversation whose context is long gone. The studio token is a credential
|
|
300
|
+
* whose value is not being asked to sign out; this is a pointer into a live call.
|
|
301
|
+
*
|
|
302
|
+
* Keyed by the agent's own URL, so two agents served from one origin — which is
|
|
303
|
+
* every deployed agent, at `/:slug/` — cannot inherit each other's session.
|
|
304
|
+
*
|
|
305
|
+
* Every access is guarded: storage throws outright in some contexts (Safari
|
|
306
|
+
* private mode, storage blocked by policy), and a session that cannot be
|
|
307
|
+
* remembered must degrade to today's behaviour rather than failing to start.
|
|
308
|
+
*/
|
|
309
|
+
const PREFIX = "aai:session:";
|
|
310
|
+
/** One agent's slot in storage. */
|
|
311
|
+
function keyFor(platformUrl) {
|
|
312
|
+
try {
|
|
313
|
+
return `${PREFIX}${new URL(platformUrl, globalThis.location?.href).href}`;
|
|
314
|
+
} catch {
|
|
315
|
+
return `${PREFIX}${platformUrl}`;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/** The stored session id for this agent, or undefined. @internal */
|
|
319
|
+
function readStoredSessionId(platformUrl) {
|
|
320
|
+
try {
|
|
321
|
+
return globalThis.sessionStorage?.getItem(keyFor(platformUrl)) ?? void 0;
|
|
322
|
+
} catch {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/** Remember this agent's session id for the next load. @internal */
|
|
327
|
+
function writeStoredSessionId(platformUrl, sessionId) {
|
|
328
|
+
try {
|
|
329
|
+
globalThis.sessionStorage?.setItem(keyFor(platformUrl), sessionId);
|
|
330
|
+
} catch {}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Forget it, so the next load is a NEW session.
|
|
334
|
+
*
|
|
335
|
+
* Called from `end()`, which is the clear-and-forget the "New Conversation"
|
|
336
|
+
* button runs: leaving the id behind there would have the next load rejoin the
|
|
337
|
+
* conversation the user just discarded, greeting suppressed.
|
|
338
|
+
*
|
|
339
|
+
* @internal
|
|
340
|
+
*/
|
|
341
|
+
function clearStoredSessionId(platformUrl) {
|
|
342
|
+
try {
|
|
343
|
+
globalThis.sessionStorage?.removeItem(keyFor(platformUrl));
|
|
344
|
+
} catch {}
|
|
345
|
+
}
|
|
346
|
+
//#endregion
|
|
347
|
+
//#region session-core-dial.ts
|
|
348
|
+
/**
|
|
349
|
+
* How the next connection attempt is DIALLED, and the resume identity it dials
|
|
350
|
+
* with.
|
|
351
|
+
*
|
|
352
|
+
* Split out of `session-core.ts` at the 500-line cap, along the seam that file
|
|
353
|
+
* already established when it moved socket plumbing into
|
|
354
|
+
* `session-core-reconnect.ts`: the state machine there reads as protocol logic,
|
|
355
|
+
* and this is the address it sends it to. What makes it one module rather than
|
|
356
|
+
* three extracted functions is that the three pieces of mutable state involved —
|
|
357
|
+
* the session id, whether this connection has ever completed a handshake, and
|
|
358
|
+
* whether the server is a broker — are read by nothing else in the core, and
|
|
359
|
+
* every one of them is only meaningful in the sentence "the URL for the next
|
|
360
|
+
* attempt".
|
|
361
|
+
*/
|
|
362
|
+
/** @internal */
|
|
363
|
+
function createDialer(options) {
|
|
364
|
+
/**
|
|
365
|
+
* The session ID to resume: seeded from `options.resumeSessionId`, else from
|
|
366
|
+
* what a previous LOAD of this page stored, then kept current from every
|
|
367
|
+
* `config` frame. Reconnect URLs carry it as `?sessionId=<id>` so the server
|
|
368
|
+
* re-registers the SAME session id — that key is what the session's slot state
|
|
369
|
+
* and event log live under, so an attempt that omits it gets a fresh session
|
|
370
|
+
* with none of the agent's context.
|
|
371
|
+
*
|
|
372
|
+
* Reading it from storage is what makes a page RELOAD resume, and so what makes
|
|
373
|
+
* the server's `syncState` push reach a UI that would otherwise come back
|
|
374
|
+
* empty. See `session-resume-store.ts`.
|
|
375
|
+
*/
|
|
376
|
+
let sessionId = options.resumeSessionId ?? readStoredSessionId(options.platformUrl);
|
|
377
|
+
/** Whether a handshake has completed on this core — the `resume=1` fallback. */
|
|
378
|
+
let hasConnected = false;
|
|
379
|
+
/**
|
|
380
|
+
* Whether `platformUrl` is a broker (its `client-config` names a
|
|
381
|
+
* `sessionUrl`). A server is one or it isn't — it never flips mid-session — so
|
|
382
|
+
* once a non-broker is observed, later reconnects skip the `client-config`
|
|
383
|
+
* re-fetch that would only fall through to `buildWsUrl` (every reconnect on
|
|
384
|
+
* `aai dev` / self-hosted otherwise pays a wasted GET). `undefined` until the
|
|
385
|
+
* first fetch settles.
|
|
386
|
+
*/
|
|
387
|
+
let serverIsBroker;
|
|
388
|
+
/**
|
|
389
|
+
* The WebSocket URL for the *next* connection attempt. Evaluated per attempt
|
|
390
|
+
* (partysocket takes it as an async URL provider):
|
|
391
|
+
*
|
|
392
|
+
* - `GET client-config` is re-fetched every attempt. When it names a
|
|
393
|
+
* `sessionUrl` — the platform's broker pointing at the agent's live sandbox
|
|
394
|
+
* — the session connects DIRECTLY there. The URL changes when the sandbox is
|
|
395
|
+
* replaced (idle eviction, redeploy), which is exactly when a reconnect
|
|
396
|
+
* happens, so per-attempt brokering is what makes reconnects land on the
|
|
397
|
+
* replacement. Without one (`aai dev`, older servers), the same-origin
|
|
398
|
+
* `websocket` path is used.
|
|
399
|
+
* - Once the first `config` arrives, every reconnect carries `?sessionId=<id>`
|
|
400
|
+
* and the server resumes the SAME session (id, tool state) instead of minting
|
|
401
|
+
* a new one. `resume=1` remains only as the greeting-suppression fallback for
|
|
402
|
+
* a server whose config carried no id.
|
|
403
|
+
*/
|
|
404
|
+
async function url() {
|
|
405
|
+
const cfg = serverIsBroker === false ? null : await loadClientConfig(options.platformUrl);
|
|
406
|
+
if (cfg) serverIsBroker = cfg.sessionUrl !== void 0;
|
|
407
|
+
return (cfg?.sessionUrl ? buildBrokeredWsUrl(cfg.sessionUrl, hasConnected, sessionId) : buildWsUrl(options.platformUrl, hasConnected, sessionId)).toString();
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
url,
|
|
411
|
+
open: () => {
|
|
412
|
+
if (options.WebSocket) return new options.WebSocket(buildWsUrl(options.platformUrl, hasConnected, sessionId).toString());
|
|
413
|
+
return openReconnectingSocket(url);
|
|
414
|
+
},
|
|
415
|
+
configured: (sid) => {
|
|
416
|
+
if (sid) {
|
|
417
|
+
sessionId = sid;
|
|
418
|
+
writeStoredSessionId(options.platformUrl, sid);
|
|
419
|
+
}
|
|
420
|
+
hasConnected = true;
|
|
421
|
+
},
|
|
422
|
+
forget: () => {
|
|
423
|
+
sessionId = void 0;
|
|
424
|
+
clearStoredSessionId(options.platformUrl);
|
|
425
|
+
hasConnected = false;
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
//#endregion
|
|
255
430
|
//#region session-core-handshake.ts
|
|
256
431
|
/**
|
|
257
432
|
* The deadline on a socket that opened but never became a session.
|
|
@@ -287,6 +462,8 @@ const HANDSHAKE_TIMEOUT_MS = 1e4;
|
|
|
287
462
|
* budget for this failure mode — without one, a permanently wedged peer would
|
|
288
463
|
* be re-dialed every ~10s forever, which is the unbounded retry loop
|
|
289
464
|
* `RECONNECT_OPTIONS.maxRetries` exists to prevent.
|
|
465
|
+
*
|
|
466
|
+
* CONSECUTIVE is the whole of it, and only `succeeded()` says so — see its doc.
|
|
290
467
|
*/
|
|
291
468
|
const MAX_HANDSHAKE_TIMEOUTS = 3;
|
|
292
469
|
/**
|
|
@@ -322,7 +499,11 @@ function createHandshakeGuard(opts) {
|
|
|
322
499
|
disarm();
|
|
323
500
|
timer = setTimeout(fire, HANDSHAKE_TIMEOUT_MS);
|
|
324
501
|
},
|
|
325
|
-
disarm
|
|
502
|
+
disarm,
|
|
503
|
+
succeeded() {
|
|
504
|
+
disarm();
|
|
505
|
+
timeouts = 0;
|
|
506
|
+
}
|
|
326
507
|
};
|
|
327
508
|
}
|
|
328
509
|
//#endregion
|
|
@@ -346,7 +527,7 @@ const MAX_MESSAGES = DEFAULT_MAX_HISTORY;
|
|
|
346
527
|
const MAX_PREINIT_AUDIO_CHUNKS = 100;
|
|
347
528
|
/**
|
|
348
529
|
* Snapshot fields cleared when a session's conversation state is wiped —
|
|
349
|
-
* shared by the initial snapshot, `resetState()`, and
|
|
530
|
+
* shared by the initial snapshot, `resetState()`, and `session.reset`.
|
|
350
531
|
* The empty arrays are safe to share: snapshot collections are never mutated
|
|
351
532
|
* in place, only replaced.
|
|
352
533
|
*/
|
|
@@ -404,7 +585,7 @@ function createMessageHandlers(deps) {
|
|
|
404
585
|
});
|
|
405
586
|
}
|
|
406
587
|
/**
|
|
407
|
-
*
|
|
588
|
+
* The agent-transcript events carry the reply's text so far as a full-replacement
|
|
408
589
|
* snapshot (see the protocol schema), so it renders as the live assistant
|
|
409
590
|
* bubble and only becomes a message when the reply closes. Pipeline mode sends
|
|
410
591
|
* one per piece of speech, so appending each would break a single reply into a
|
|
@@ -418,7 +599,7 @@ function createMessageHandlers(deps) {
|
|
|
418
599
|
updateState({ agentTranscript: text });
|
|
419
600
|
}
|
|
420
601
|
/**
|
|
421
|
-
* The reply is over (`
|
|
602
|
+
* The reply is over (`reply.completed`, or `reply.cancelled` for a barge-in): move
|
|
422
603
|
* whatever was spoken into the conversation. A cancelled reply still keeps its
|
|
423
604
|
* text — the caller heard that much, and dropping it would leave the
|
|
424
605
|
* transcript claiming the agent never spoke.
|
|
@@ -459,7 +640,8 @@ function createMessageHandlers(deps) {
|
|
|
459
640
|
/**
|
|
460
641
|
* Return to "listening" at a turn boundary — unless the session is over.
|
|
461
642
|
*
|
|
462
|
-
* `
|
|
643
|
+
* `reply.completed`, `reply.cancelled` and `session.reset` each wrote
|
|
644
|
+
* `state: "listening"`
|
|
463
645
|
* unconditionally, which is the second half of the same bug
|
|
464
646
|
* `clearRecoveredError`'s latch covers: the host's fatal paths all call
|
|
465
647
|
* `terminate()`, and terminating emits `onCancelled()`. So the frame that
|
|
@@ -495,22 +677,23 @@ function createMessageHandlers(deps) {
|
|
|
495
677
|
}
|
|
496
678
|
/** Single entry point for all server->client session events. */
|
|
497
679
|
function handleEvent(e) {
|
|
498
|
-
if (e.type !== "error") clearRecoveredError();
|
|
680
|
+
if (e.type !== "error.reported") clearRecoveredError();
|
|
499
681
|
switch (e.type) {
|
|
500
|
-
case "
|
|
682
|
+
case "speech.started":
|
|
501
683
|
updateState({ userTranscript: "" });
|
|
502
684
|
break;
|
|
503
|
-
case "
|
|
504
|
-
case "
|
|
685
|
+
case "speech.stopped": break;
|
|
686
|
+
case "user-transcript.committed":
|
|
505
687
|
handleUserTranscriptEvent(e.text);
|
|
506
688
|
break;
|
|
507
|
-
case "
|
|
689
|
+
case "user-transcript.updated":
|
|
508
690
|
updateState({ userTranscript: e.text });
|
|
509
691
|
break;
|
|
510
|
-
case "
|
|
692
|
+
case "agent-transcript.updated":
|
|
693
|
+
case "agent-transcript.committed":
|
|
511
694
|
handleAgentTranscriptEvent(e.text);
|
|
512
695
|
break;
|
|
513
|
-
case "
|
|
696
|
+
case "tool.called":
|
|
514
697
|
updateState({ toolCalls: appendCapped(getSnapshot().toolCalls, {
|
|
515
698
|
callId: e.toolCallId,
|
|
516
699
|
name: e.toolName,
|
|
@@ -520,7 +703,7 @@ function createMessageHandlers(deps) {
|
|
|
520
703
|
afterMessageId: getSnapshot().messages.at(-1)?.id ?? -1
|
|
521
704
|
}, MAX_MESSAGES) });
|
|
522
705
|
break;
|
|
523
|
-
case "
|
|
706
|
+
case "tool.completed": {
|
|
524
707
|
const tcs = getSnapshot().toolCalls;
|
|
525
708
|
const idx = tcs.findIndex((tc) => tc.callId === e.toolCallId);
|
|
526
709
|
if (idx !== -1) {
|
|
@@ -535,31 +718,53 @@ function createMessageHandlers(deps) {
|
|
|
535
718
|
}
|
|
536
719
|
break;
|
|
537
720
|
}
|
|
538
|
-
case "
|
|
721
|
+
case "reply.completed":
|
|
539
722
|
commitAgentTranscript();
|
|
540
723
|
toListening();
|
|
541
724
|
break;
|
|
542
|
-
case "cancelled":
|
|
725
|
+
case "reply.cancelled":
|
|
543
726
|
conn.turn.bump();
|
|
544
727
|
conn.voiceIO?.flush();
|
|
545
728
|
commitAgentTranscript();
|
|
546
729
|
toListening({ userTranscript: null });
|
|
547
730
|
break;
|
|
548
|
-
case "reset":
|
|
731
|
+
case "session.reset":
|
|
549
732
|
conn.turn.bump();
|
|
550
733
|
conn.voiceIO?.flush();
|
|
551
734
|
toListening(conn.fatalError ? {} : CLEARED_SESSION_STATE);
|
|
552
735
|
break;
|
|
553
|
-
case "
|
|
736
|
+
case "custom.emitted":
|
|
554
737
|
appendCustomEvent(e.event, e.data);
|
|
555
738
|
break;
|
|
556
|
-
case "
|
|
739
|
+
case "state.updated":
|
|
557
740
|
updateState({ agentState: e.state });
|
|
558
741
|
break;
|
|
559
|
-
case "
|
|
742
|
+
case "history.restored": {
|
|
743
|
+
messageSeq = 0;
|
|
744
|
+
toolCallSeq = 0;
|
|
745
|
+
const restored = e.messages.slice(-MAX_MESSAGES).map((m) => ({
|
|
746
|
+
id: ++messageSeq,
|
|
747
|
+
role: m.role,
|
|
748
|
+
content: m.content
|
|
749
|
+
}));
|
|
750
|
+
updateState({
|
|
751
|
+
messages: restored,
|
|
752
|
+
toolCalls: e.toolCalls.slice(-MAX_MESSAGES).map((tc) => ({
|
|
753
|
+
callId: tc.callId,
|
|
754
|
+
name: tc.name,
|
|
755
|
+
args: toArgsRecord(tc.args),
|
|
756
|
+
status: tc.status,
|
|
757
|
+
...omitUndefined({ result: tc.result }),
|
|
758
|
+
seq: ++toolCallSeq,
|
|
759
|
+
afterMessageId: restored[tc.afterMessageIndex]?.id ?? -1
|
|
760
|
+
}))
|
|
761
|
+
});
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
case "error.reported":
|
|
560
765
|
handleErrorEvent(e);
|
|
561
766
|
break;
|
|
562
|
-
case "
|
|
767
|
+
case "session.timed-out":
|
|
563
768
|
deps.conn.retiredByServer = true;
|
|
564
769
|
break;
|
|
565
770
|
default: break;
|
|
@@ -619,7 +824,7 @@ function createMessageHandlers(deps) {
|
|
|
619
824
|
return;
|
|
620
825
|
}
|
|
621
826
|
const msg = parsed.data;
|
|
622
|
-
if (msg.type === "
|
|
827
|
+
if (msg.type === "session.configured") {
|
|
623
828
|
conn.fatalError = false;
|
|
624
829
|
return {
|
|
625
830
|
sampleRate: msg.sampleRate,
|
|
@@ -627,7 +832,7 @@ function createMessageHandlers(deps) {
|
|
|
627
832
|
sid: msg.sessionId
|
|
628
833
|
};
|
|
629
834
|
}
|
|
630
|
-
if (msg.type === "
|
|
835
|
+
if (msg.type === "audio.completed") {
|
|
631
836
|
playAudioDone();
|
|
632
837
|
return;
|
|
633
838
|
}
|
|
@@ -639,29 +844,6 @@ function createMessageHandlers(deps) {
|
|
|
639
844
|
};
|
|
640
845
|
}
|
|
641
846
|
//#endregion
|
|
642
|
-
//#region session-core-url.ts
|
|
643
|
-
/** Build the session WebSocket URL from the platform URL and resume state. */
|
|
644
|
-
function buildWsUrl(platformUrl, resume, sessionId) {
|
|
645
|
-
return applyResumeParams(buildAgentUrl(platformUrl, "websocket"), resume, sessionId);
|
|
646
|
-
}
|
|
647
|
-
/**
|
|
648
|
-
* Turn a broker-provided session URL (`sessionUrl` from `GET client-config`
|
|
649
|
-
* — the agent's live sandbox endpoint) into this attempt's connect URL.
|
|
650
|
-
*/
|
|
651
|
-
function buildBrokeredWsUrl(sessionUrl, resume, sessionId) {
|
|
652
|
-
return applyResumeParams(new URL(sessionUrl), resume, sessionId);
|
|
653
|
-
}
|
|
654
|
-
const WS_PROTOCOLS = {
|
|
655
|
-
"https:": "wss:",
|
|
656
|
-
"http:": "ws:"
|
|
657
|
-
};
|
|
658
|
-
function applyResumeParams(wsUrl, resume, sessionId) {
|
|
659
|
-
wsUrl.protocol = WS_PROTOCOLS[wsUrl.protocol] ?? wsUrl.protocol;
|
|
660
|
-
if (sessionId) wsUrl.searchParams.set("sessionId", sessionId);
|
|
661
|
-
else if (resume) wsUrl.searchParams.set("resume", "1");
|
|
662
|
-
return wsUrl;
|
|
663
|
-
}
|
|
664
|
-
//#endregion
|
|
665
847
|
//#region session-core.ts
|
|
666
848
|
/**
|
|
667
849
|
* Framework-agnostic voice session core.
|
|
@@ -755,25 +937,7 @@ function createSessionCore(options) {
|
|
|
755
937
|
preInitDone: false
|
|
756
938
|
};
|
|
757
939
|
let connectionController = null;
|
|
758
|
-
|
|
759
|
-
/**
|
|
760
|
-
* The session ID to resume: seeded from `options.resumeSessionId`, then
|
|
761
|
-
* kept current from every `config` frame. Reconnect URLs carry it as
|
|
762
|
-
* `?sessionId=<id>` so the server re-registers the SAME session id —
|
|
763
|
-
* that key is what per-session tool state (`ctx.state`) lives under, so
|
|
764
|
-
* a reconnect that omits it gets a fresh session with none of the
|
|
765
|
-
* agent's context, greeting suppression aside.
|
|
766
|
-
*/
|
|
767
|
-
let sessionId = options.resumeSessionId;
|
|
768
|
-
/**
|
|
769
|
-
* Whether `platformUrl` is a broker (its `client-config` names a
|
|
770
|
-
* `sessionUrl`). A server is one or it isn't — it never flips mid-session
|
|
771
|
-
* — so once a non-broker is observed, later reconnects skip the
|
|
772
|
-
* `client-config` re-fetch that would only fall through to `buildWsUrl`
|
|
773
|
-
* (every reconnect on `aai dev` / self-hosted otherwise pays a wasted GET).
|
|
774
|
-
* `undefined` until the first fetch settles.
|
|
775
|
-
*/
|
|
776
|
-
let serverIsBroker;
|
|
940
|
+
const dialer = createDialer(options);
|
|
777
941
|
function cleanupAudio() {
|
|
778
942
|
conn.audioSetupInFlight = false;
|
|
779
943
|
conn.turn.bump();
|
|
@@ -814,51 +978,21 @@ function createSessionCore(options) {
|
|
|
814
978
|
conn.ws?.close();
|
|
815
979
|
conn.ws = null;
|
|
816
980
|
}
|
|
817
|
-
/** React to the server's `config` message: record it, set up the audio
|
|
818
|
-
* path for the session's mode, and replay history on reconnect. */
|
|
819
|
-
function onServerConfig(config) {
|
|
820
|
-
if (config.sid) {
|
|
821
|
-
sessionId = config.sid;
|
|
822
|
-
options.onSessionId?.(config.sid);
|
|
823
|
-
}
|
|
824
|
-
const isReconnect = hasConnected;
|
|
825
|
-
hasConnected = true;
|
|
826
|
-
initAudioCapture(conn, config, audioDeps);
|
|
827
|
-
if (isReconnect && currentSnapshot.messages.length > 0) sendJson({
|
|
828
|
-
type: "history",
|
|
829
|
-
messages: currentSnapshot.messages.map((m) => ({
|
|
830
|
-
role: m.role,
|
|
831
|
-
content: m.content
|
|
832
|
-
}))
|
|
833
|
-
});
|
|
834
|
-
}
|
|
835
981
|
/**
|
|
836
|
-
*
|
|
837
|
-
*
|
|
982
|
+
* React to the server's `session.configured` frame: record it and set up the
|
|
983
|
+
* session's audio path.
|
|
838
984
|
*
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
* servers), the same-origin `websocket` path is used.
|
|
846
|
-
* - Once the first `config` arrives, every reconnect carries
|
|
847
|
-
* `?sessionId=<id>` and the server resumes the SAME session (id, tool
|
|
848
|
-
* state) instead of minting a new one. `resume=1` remains only as the
|
|
849
|
-
* greeting-suppression fallback for a server whose config carried no id.
|
|
985
|
+
* **It no longer replays history, and the deletion is the point.** A reconnect
|
|
986
|
+
* used to push this snapshot's `messages` back, making the CLIENT the authority
|
|
987
|
+
* on the agent's memory; the server restores the conversation from its own
|
|
988
|
+
* retained event stream now, which also covers what a client cannot — a second
|
|
989
|
+
* tab, a call resuming onto a replacement sandbox, a reopened tab. This
|
|
990
|
+
* snapshot's `messages` are untouched: nothing clears the transcript on screen.
|
|
850
991
|
*/
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
if (
|
|
854
|
-
|
|
855
|
-
}
|
|
856
|
-
/** Open a socket: an injected constructor as-is (tests — connects to the
|
|
857
|
-
* same-origin path, no brokering), or partysocket's reconnecting
|
|
858
|
-
* WebSocket — same interface, plus reconnect-on-close. */
|
|
859
|
-
function openSocket() {
|
|
860
|
-
if (options.WebSocket) return new options.WebSocket(buildWsUrl(options.platformUrl, hasConnected, sessionId).toString());
|
|
861
|
-
return openReconnectingSocket(currentWsUrl);
|
|
992
|
+
function onServerConfig(config) {
|
|
993
|
+
dialer.configured(config.sid);
|
|
994
|
+
if (config.sid) options.onSessionId?.(config.sid);
|
|
995
|
+
initAudioCapture(conn, config, audioDeps);
|
|
862
996
|
}
|
|
863
997
|
function connect(opts) {
|
|
864
998
|
if (opts?.signal?.aborted) {
|
|
@@ -877,7 +1011,7 @@ function createSessionCore(options) {
|
|
|
877
1011
|
connectionController = controller;
|
|
878
1012
|
const { signal: sig } = controller;
|
|
879
1013
|
if (opts?.signal) opts.signal.addEventListener("abort", () => disconnect(), { signal: sig });
|
|
880
|
-
const socket =
|
|
1014
|
+
const socket = dialer.open();
|
|
881
1015
|
socket.binaryType = "arraybuffer";
|
|
882
1016
|
conn.ws = socket;
|
|
883
1017
|
let socketErrored = false;
|
|
@@ -912,7 +1046,7 @@ function createSessionCore(options) {
|
|
|
912
1046
|
socket.addEventListener("message", (event) => {
|
|
913
1047
|
const config = handleMessage(event.data);
|
|
914
1048
|
if (!config) return;
|
|
915
|
-
handshake.
|
|
1049
|
+
handshake.succeeded();
|
|
916
1050
|
onServerConfig(config);
|
|
917
1051
|
}, { signal: sig });
|
|
918
1052
|
socket.addEventListener("error", () => {
|
|
@@ -996,8 +1130,7 @@ function createSessionCore(options) {
|
|
|
996
1130
|
}
|
|
997
1131
|
function end() {
|
|
998
1132
|
teardownConnection();
|
|
999
|
-
|
|
1000
|
-
hasConnected = false;
|
|
1133
|
+
dialer.forget();
|
|
1001
1134
|
updateState({
|
|
1002
1135
|
...CLEARED_SESSION_STATE,
|
|
1003
1136
|
state: "disconnected",
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the next connection attempt is DIALLED, and the resume identity it dials
|
|
3
|
+
* with.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `session-core.ts` at the 500-line cap, along the seam that file
|
|
6
|
+
* already established when it moved socket plumbing into
|
|
7
|
+
* `session-core-reconnect.ts`: the state machine there reads as protocol logic,
|
|
8
|
+
* and this is the address it sends it to. What makes it one module rather than
|
|
9
|
+
* three extracted functions is that the three pieces of mutable state involved —
|
|
10
|
+
* the session id, whether this connection has ever completed a handshake, and
|
|
11
|
+
* whether the server is a broker — are read by nothing else in the core, and
|
|
12
|
+
* every one of them is only meaningful in the sentence "the URL for the next
|
|
13
|
+
* attempt".
|
|
14
|
+
*/
|
|
15
|
+
import type { WebSocketConstructor } from "./types.ts";
|
|
16
|
+
/** What the dialer needs from the session's options. */
|
|
17
|
+
export type DialOptions = {
|
|
18
|
+
platformUrl: string;
|
|
19
|
+
/** Tests inject one; it connects to the same-origin path and never reconnects. */
|
|
20
|
+
WebSocket?: WebSocketConstructor | undefined;
|
|
21
|
+
/** An id the caller manages itself — wins over what a previous load stored. */
|
|
22
|
+
resumeSessionId?: string | undefined;
|
|
23
|
+
};
|
|
24
|
+
export type Dialer = {
|
|
25
|
+
/** The URL for the next attempt — partysocket's async provider. */
|
|
26
|
+
url(): Promise<string>;
|
|
27
|
+
/** A socket for this attempt. */
|
|
28
|
+
open(): InstanceType<WebSocketConstructor>;
|
|
29
|
+
/**
|
|
30
|
+
* A completed handshake: adopt the server's session id and record that this
|
|
31
|
+
* connection has been established, so every later attempt resumes.
|
|
32
|
+
*/
|
|
33
|
+
configured(sid: string | undefined): void;
|
|
34
|
+
/** Drop the resume identity, so the next connect is a NEW session. */
|
|
35
|
+
forget(): void;
|
|
36
|
+
};
|
|
37
|
+
/** @internal */
|
|
38
|
+
export declare function createDialer(options: DialOptions): Dialer;
|