@alexkroman1/aai-ui 4.0.0 → 5.0.1
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/dist/client-config.d.ts +22 -4
- package/dist/default-client/assets/{audio-DQseUQnN.js → audio-BdEKgLwT.js} +1 -1
- package/dist/default-client/assets/{capture-processor-DKFNcEhw.js → capture-processor-_QJe3nzY.js} +1 -1
- package/dist/default-client/assets/index-RQnhfgSS.js +192 -0
- package/dist/default-client/assets/{playback-processor-HeyncQUX.js → playback-processor-C_F19eUJ.js} +1 -1
- package/dist/default-client/index.html +1 -1
- package/dist/define-client.d.ts +12 -1
- package/dist/define-client.js +120 -4
- package/dist/hooks.d.ts +40 -2
- package/dist/hooks.js +24 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -4
- package/dist/{session-core-CkZ8LyEA.js → session-core-ByBLnLfW.js} +126 -35
- package/dist/session-core-messages.d.ts +2 -1
- package/dist/session-core-reconnect.d.ts +4 -3
- package/dist/session-core-types.d.ts +11 -3
- package/dist/session-core-url.d.ts +5 -0
- package/dist/session-core.js +1 -1
- package/dist/types.d.ts +17 -1
- package/package.json +2 -2
- package/dist/default-client/assets/index-CkZ6t_eJ.js +0 -417
- package/dist/define-client-DqRs1XPd.js +0 -150
package/dist/default-client/assets/{playback-processor-HeyncQUX.js → playback-processor-C_F19eUJ.js}
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{i as e}from"./index-
|
|
1
|
+
import{i as e}from"./index-RQnhfgSS.js";import{t}from"./_module-url-BX0RuRU2.js";var n=t(`
|
|
2
2
|
class PlaybackProcessor extends AudioWorkletProcessor {
|
|
3
3
|
constructor(options) {
|
|
4
4
|
super();
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<title>aai</title>
|
|
7
7
|
<link rel="icon" href="./favicon.ico" />
|
|
8
8
|
<style>html, body { background: #FBF8F2; margin: 0; }</style>
|
|
9
|
-
<script type="module" crossorigin src="./assets/index-
|
|
9
|
+
<script type="module" crossorigin src="./assets/index-RQnhfgSS.js"></script>
|
|
10
10
|
<link rel="stylesheet" crossorigin href="./assets/index-CRfdZPH1.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
package/dist/define-client.d.ts
CHANGED
|
@@ -46,7 +46,18 @@ type ConfigTier = BaseOptions & {
|
|
|
46
46
|
type ComponentTier = BaseOptions & {
|
|
47
47
|
/** Full custom component to render instead of the default shell. */
|
|
48
48
|
component: ComponentType;
|
|
49
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Agent name. With a custom component there is no shell header to put it
|
|
51
|
+
* in, so it becomes the page title.
|
|
52
|
+
*
|
|
53
|
+
* Allowed here rather than `never` because `client({ name, component })` is
|
|
54
|
+
* the natural thing to write and two different models wrote it. As `never`
|
|
55
|
+
* it failed with *"Type 'string' is not assignable to type 'undefined'"*,
|
|
56
|
+
* which explains nothing, and cost a build round each time. There is a real
|
|
57
|
+
* use for the value — a custom-UI page otherwise inherits whatever title
|
|
58
|
+
* the HTML shell shipped with — so it is honoured instead of banned.
|
|
59
|
+
*/
|
|
60
|
+
name?: string;
|
|
50
61
|
sidebar?: never;
|
|
51
62
|
sidebarWidth?: never;
|
|
52
63
|
tools?: never;
|
package/dist/define-client.js
CHANGED
|
@@ -1,5 +1,121 @@
|
|
|
1
|
-
import { t as
|
|
2
|
-
import "./context.js";
|
|
3
|
-
import "./
|
|
4
|
-
import "./
|
|
1
|
+
import { r as fetchClientConfig, t as createSessionCore } from "./session-core-ByBLnLfW.js";
|
|
2
|
+
import { SessionProvider, ThemeProvider } from "./context.js";
|
|
3
|
+
import { t as ChatView } from "./chat-view-CW-tZCMm.js";
|
|
4
|
+
import { n as ToolConfigContext } from "./tool-call-block-DIxpG8GM.js";
|
|
5
|
+
import { SidebarLayout } from "./components/sidebar-layout.js";
|
|
6
|
+
import { StartScreen } from "./components/start-screen.js";
|
|
7
|
+
import { createElement, useEffect, useState } from "react";
|
|
8
|
+
import { jsx } from "react/jsx-runtime";
|
|
9
|
+
import { flushSync } from "react-dom";
|
|
10
|
+
import { createRoot } from "react-dom/client";
|
|
11
|
+
//#region define-client.tsx
|
|
12
|
+
/** @jsxImportSource react */
|
|
13
|
+
function resolveContainer(target = "#app") {
|
|
14
|
+
if (typeof target !== "string") return target;
|
|
15
|
+
const el = document.querySelector(target);
|
|
16
|
+
if (!el) throw new Error(`Element not found: ${target}`);
|
|
17
|
+
return el;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Default shell rendered in config tier.
|
|
21
|
+
* Wraps StartScreen → (SidebarLayout →) ChatView.
|
|
22
|
+
*/
|
|
23
|
+
function DefaultShell({ name, Sidebar, sidebarWidth }) {
|
|
24
|
+
const chat = /* @__PURE__ */ jsx(ChatView, { title: name });
|
|
25
|
+
return /* @__PURE__ */ jsx(StartScreen, {
|
|
26
|
+
title: name,
|
|
27
|
+
children: Sidebar ? /* @__PURE__ */ jsx(SidebarLayout, {
|
|
28
|
+
sidebar: /* @__PURE__ */ jsx(Sidebar, {}),
|
|
29
|
+
sidebarWidth,
|
|
30
|
+
children: chat
|
|
31
|
+
}) : chat
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Config-tier root: fetches the server-declared display name via
|
|
36
|
+
* `GET client-config` and renders the chat shell. The shell renders
|
|
37
|
+
* immediately — optimistically — while the lookup is in flight; servers
|
|
38
|
+
* without the endpoint (every lookup failure resolves to the empty default)
|
|
39
|
+
* work exactly as before.
|
|
40
|
+
*/
|
|
41
|
+
function DefaultRoot({ platformUrl, name, Sidebar, sidebarWidth }) {
|
|
42
|
+
const [resolved, setResolved] = useState(null);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
let cancelled = false;
|
|
45
|
+
fetchClientConfig(platformUrl).then((cfg) => {
|
|
46
|
+
if (!cancelled) setResolved(cfg);
|
|
47
|
+
});
|
|
48
|
+
return () => {
|
|
49
|
+
cancelled = true;
|
|
50
|
+
};
|
|
51
|
+
}, [platformUrl]);
|
|
52
|
+
return /* @__PURE__ */ jsx(DefaultShell, {
|
|
53
|
+
name: name ?? resolved?.name,
|
|
54
|
+
Sidebar,
|
|
55
|
+
sidebarWidth
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Define and mount a client UI for a voice agent.
|
|
60
|
+
*
|
|
61
|
+
* **Tier 1 (config-only):** Pass options without `component` to get the
|
|
62
|
+
* default shell (StartScreen + ChatView, optional sidebar).
|
|
63
|
+
*
|
|
64
|
+
* **Tier 2 (custom component):** Pass `component` to render a fully custom
|
|
65
|
+
* root component inside the providers.
|
|
66
|
+
*
|
|
67
|
+
* @example Tier 1
|
|
68
|
+
* ```tsx
|
|
69
|
+
* client({
|
|
70
|
+
* name: "Pizza Ordering",
|
|
71
|
+
* theme: { bg: "#1a1a1a", primary: "#e55" },
|
|
72
|
+
* sidebar: OrderPanel,
|
|
73
|
+
* tools: { add_pizza: { icon: "🍕", label: "Adding pizza" } },
|
|
74
|
+
* });
|
|
75
|
+
* ```
|
|
76
|
+
*
|
|
77
|
+
* @example Tier 2
|
|
78
|
+
* ```tsx
|
|
79
|
+
* client({ component: MyCustomApp });
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* @returns A {@link ClientHandle} for cleanup.
|
|
83
|
+
* @throws If the target element is not found in the DOM.
|
|
84
|
+
*
|
|
85
|
+
* @public
|
|
86
|
+
*/
|
|
87
|
+
function client(config) {
|
|
88
|
+
const container = resolveContainer(config.target);
|
|
89
|
+
const platformUrl = config.platformUrl ?? globalThis.location.origin + globalThis.location.pathname;
|
|
90
|
+
const session = createSessionCore({
|
|
91
|
+
platformUrl,
|
|
92
|
+
onSessionId: config.onSessionId,
|
|
93
|
+
resumeSessionId: config.resumeSessionId,
|
|
94
|
+
WebSocket: config.WebSocket
|
|
95
|
+
});
|
|
96
|
+
if (config.name && typeof document !== "undefined") document.title = config.name;
|
|
97
|
+
const rootNode = config.component ? createElement(config.component) : createElement(DefaultRoot, {
|
|
98
|
+
platformUrl,
|
|
99
|
+
name: config.name,
|
|
100
|
+
Sidebar: config.sidebar,
|
|
101
|
+
sidebarWidth: config.sidebarWidth
|
|
102
|
+
});
|
|
103
|
+
const toolConfig = config.tools ?? {};
|
|
104
|
+
const root = createRoot(container);
|
|
105
|
+
flushSync(() => {
|
|
106
|
+
root.render(createElement(ToolConfigContext.Provider, { value: toolConfig }, createElement(ThemeProvider, { value: config.theme }, createElement(SessionProvider, { value: session }, rootNode))));
|
|
107
|
+
});
|
|
108
|
+
const handle = {
|
|
109
|
+
session,
|
|
110
|
+
dispose() {
|
|
111
|
+
root.unmount();
|
|
112
|
+
session[Symbol.dispose]();
|
|
113
|
+
},
|
|
114
|
+
[Symbol.dispose]() {
|
|
115
|
+
handle.dispose();
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
return handle;
|
|
119
|
+
}
|
|
120
|
+
//#endregion
|
|
5
121
|
export { client };
|
package/dist/hooks.d.ts
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
|
+
import type { DefaultToolResult } from "@alexkroman1/aai";
|
|
1
2
|
import type { ToolCallInfo } from "./types.ts";
|
|
2
|
-
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Fire a callback when a tool call settles, with the tool's JSON result.
|
|
5
|
+
*
|
|
6
|
+
* @typeParam R - The result shape. Defaults to {@link DefaultToolResult} —
|
|
7
|
+
* `any`, for the same reason `ctx.state` is: the value is the author's own
|
|
8
|
+
* tool output round-tripped through JSON, so `unknown` (which this was) made
|
|
9
|
+
* the ordinary spelling
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* useToolResult("get_quote", (r) => setPrice(r.price))
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* a compile error on a client that runs correctly, and `aai build` refuses
|
|
16
|
+
* to publish it. Pass the shape — `useToolResult<Quote>(…)` — to get real
|
|
17
|
+
* checking, which also documents what the tool returns.
|
|
18
|
+
*/
|
|
19
|
+
export declare function useToolResult<R = DefaultToolResult>(toolName: string, callback: (result: R, toolCall: ToolCallInfo) => void): void;
|
|
20
|
+
export declare function useToolResult<R = DefaultToolResult>(callback: (name: string, result: R, toolCall: ToolCallInfo) => void): void;
|
|
21
|
+
/**
|
|
22
|
+
* The agent's projected session state, or `null` before the first push.
|
|
23
|
+
*
|
|
24
|
+
* The counterpart to `syncState` on the agent: whatever that projection
|
|
25
|
+
* returns is what arrives here. It replaces the pattern this exists to
|
|
26
|
+
* remove — returning a state snapshot from every tool, declaring a type for
|
|
27
|
+
* what those tools happen to return, and mirroring it into `useState` through
|
|
28
|
+
* `useToolResult`, which was three things to keep in step and which 58% of
|
|
29
|
+
* measured generated agents built by hand.
|
|
30
|
+
*
|
|
31
|
+
* ```tsx
|
|
32
|
+
* const cart = useAgentState<{ cart: Item[] }>();
|
|
33
|
+
* return <Cart items={cart?.cart ?? []} />;
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* Typed by the caller for the same reason `useToolResult` is: the shape is
|
|
37
|
+
* the author's own projection, which the framework cannot see. It is
|
|
38
|
+
* nullable on purpose — nothing has been pushed before the first tool call,
|
|
39
|
+
* and a UI has to render that moment.
|
|
40
|
+
*/
|
|
41
|
+
export declare function useAgentState<S = DefaultToolResult>(): S | null;
|
|
4
42
|
export declare function useEvent<T = unknown>(event: string, callback: (data: T) => void): void;
|
|
5
43
|
export declare function useToolCallStart(toolName: string, callback: (toolCall: ToolCallInfo) => void): void;
|
|
6
44
|
export declare function useToolCallStart(callback: (toolCall: ToolCallInfo) => void): void;
|
package/dist/hooks.js
CHANGED
|
@@ -77,6 +77,29 @@ function useToolResult(...args) {
|
|
|
77
77
|
else callback(tc.name, parsed, tc);
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* The agent's projected session state, or `null` before the first push.
|
|
82
|
+
*
|
|
83
|
+
* The counterpart to `syncState` on the agent: whatever that projection
|
|
84
|
+
* returns is what arrives here. It replaces the pattern this exists to
|
|
85
|
+
* remove — returning a state snapshot from every tool, declaring a type for
|
|
86
|
+
* what those tools happen to return, and mirroring it into `useState` through
|
|
87
|
+
* `useToolResult`, which was three things to keep in step and which 58% of
|
|
88
|
+
* measured generated agents built by hand.
|
|
89
|
+
*
|
|
90
|
+
* ```tsx
|
|
91
|
+
* const cart = useAgentState<{ cart: Item[] }>();
|
|
92
|
+
* return <Cart items={cart?.cart ?? []} />;
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* Typed by the caller for the same reason `useToolResult` is: the shape is
|
|
96
|
+
* the author's own projection, which the framework cannot see. It is
|
|
97
|
+
* nullable on purpose — nothing has been pushed before the first tool call,
|
|
98
|
+
* and a UI has to render that moment.
|
|
99
|
+
*/
|
|
100
|
+
function useAgentState() {
|
|
101
|
+
return useSessionSelector((snapshot) => snapshot.agentState);
|
|
102
|
+
}
|
|
80
103
|
function useEvent(event, callback) {
|
|
81
104
|
const customEvents = useSessionSelector((s) => s.customEvents);
|
|
82
105
|
const watermarkRef = useRef(0);
|
|
@@ -102,4 +125,4 @@ function useToolCallStart(...args) {
|
|
|
102
125
|
});
|
|
103
126
|
}
|
|
104
127
|
//#endregion
|
|
105
|
-
export { useEvent, useToolCallStart, useToolResult };
|
|
128
|
+
export { useAgentState, useEvent, useToolCallStart, useToolResult };
|
package/dist/index.d.ts
CHANGED
|
@@ -13,8 +13,8 @@ export type { Session } from "./context.ts";
|
|
|
13
13
|
export { SessionProvider, ThemeProvider, useSession, useSessionSelector, useTheme, } from "./context.ts";
|
|
14
14
|
export type { ClientConfig, ClientHandle, } from "./define-client.tsx";
|
|
15
15
|
export { client } from "./define-client.tsx";
|
|
16
|
-
export { useEvent, useToolCallStart, useToolResult } from "./hooks.ts";
|
|
16
|
+
export { useAgentState, useEvent, useToolCallStart, useToolResult } from "./hooks.ts";
|
|
17
17
|
export { createSessionCore } from "./session-core.ts";
|
|
18
18
|
export type { CustomEvent, SessionCore, SessionCoreOptions, SessionSnapshot, } from "./session-core-types.ts";
|
|
19
|
-
export type { AgentState, ChatMessage, ClientTheme, SessionError, SessionErrorCode, ToolCallInfo, VoiceSessionOptions,
|
|
19
|
+
export type { AgentState, ChatMessage, ClientTheme, SessionError, SessionErrorCode, ToolCallInfo, VoiceSessionOptions, } from "./types.ts";
|
|
20
20
|
export { VOICE_CAPTURE_CONSTRAINTS } from "./types.ts";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as buildAgentUrl, r as fetchClientConfig, t as
|
|
1
|
+
import { n as buildAgentUrl, r as fetchClientConfig, t as createSessionCore } from "./session-core-ByBLnLfW.js";
|
|
2
2
|
import { SessionProvider, ThemeProvider, useSession, useSessionSelector, useTheme } from "./context.js";
|
|
3
3
|
import { Button } from "./components/button.js";
|
|
4
4
|
import { t as ChatView } from "./chat-view-CW-tZCMm.js";
|
|
@@ -7,7 +7,7 @@ import { n as Markdown, t as MessageList } from "./message-list-q2lTiFhF.js";
|
|
|
7
7
|
import { n as ToolConfigContext } from "./tool-call-block-DIxpG8GM.js";
|
|
8
8
|
import { SidebarLayout } from "./components/sidebar-layout.js";
|
|
9
9
|
import { StartScreen } from "./components/start-screen.js";
|
|
10
|
-
import { t as createSessionCore } from "./session-core-CkZ8LyEA.js";
|
|
11
10
|
import { VOICE_CAPTURE_CONSTRAINTS } from "./types.js";
|
|
12
|
-
import {
|
|
13
|
-
|
|
11
|
+
import { client } from "./define-client.js";
|
|
12
|
+
import { useAgentState, useEvent, useToolCallStart, useToolResult } from "./hooks.js";
|
|
13
|
+
export { ApiUrlChip, Button, ChatView, Controls, Markdown, MessageList, SessionProvider, SessionUrlChips, SidebarLayout, StartScreen, ThemeProvider, ToolConfigContext, UiUrlChip, VOICE_CAPTURE_CONSTRAINTS, buildAgentUrl, client, createSessionCore, fetchClientConfig, useAgentState, useEvent, useSession, useSessionSelector, useTheme, useToolCallStart, useToolResult };
|
|
@@ -1,7 +1,56 @@
|
|
|
1
1
|
import { MIC_SEND_MAX_BUFFERED_BYTES } from "./types.js";
|
|
2
|
-
import { ServerMessageSchema, lenientParse } from "@alexkroman1/aai/protocol";
|
|
3
|
-
import { DEFAULT_MAX_HISTORY, WS_OPEN, errorMessage, safeJsonParse } from "@alexkroman1/aai";
|
|
2
|
+
import { CLIENT_CONFIG_PATH, ClientConfigResponseSchema, ServerMessageSchema, lenientParse } from "@alexkroman1/aai/protocol";
|
|
3
|
+
import { DEFAULT_MAX_HISTORY, WS_OPEN, createEpoch, errorMessage, safeJsonParse } from "@alexkroman1/aai";
|
|
4
4
|
import ReconnectingWebSocket from "partysocket/ws";
|
|
5
|
+
//#region client-config.ts
|
|
6
|
+
/**
|
|
7
|
+
* Pre-connection client-config lookup.
|
|
8
|
+
*
|
|
9
|
+
* `GET client-config` (relative to the agent's base URL — see
|
|
10
|
+
* `sdk/client-config.ts` in `@alexkroman1/aai`) gives the default client the
|
|
11
|
+
* agent's display name and greeting before any connection exists. For that
|
|
12
|
+
* use every failure path — network error, 404 from an older server,
|
|
13
|
+
* malformed body — degrades to the empty default (`fetchClientConfig`), so
|
|
14
|
+
* the lookup can never break an existing agent.
|
|
15
|
+
*
|
|
16
|
+
* The session's broker decision needs the opposite: `loadClientConfig`
|
|
17
|
+
* keeps "the lookup failed" (`null`) distinct from "the server answered and
|
|
18
|
+
* named no sessionUrl" (`{}`). See its doc comment.
|
|
19
|
+
*/
|
|
20
|
+
/** Resolve a relative endpoint path against the agent's base URL. */
|
|
21
|
+
function buildAgentUrl(platformUrl, endpointPath) {
|
|
22
|
+
return new URL(endpointPath, platformUrl.endsWith("/") ? platformUrl : `${platformUrl}/`);
|
|
23
|
+
}
|
|
24
|
+
const AGENT_DEFAULT = {};
|
|
25
|
+
/**
|
|
26
|
+
* Fetch the agent's client config, reporting `null` when the lookup did not
|
|
27
|
+
* produce an answer (network error, non-2xx, unparsable body).
|
|
28
|
+
*
|
|
29
|
+
* The distinction from `fetchClientConfig` matters for exactly one caller:
|
|
30
|
+
* the session's per-attempt broker decision. A config that ARRIVED and named
|
|
31
|
+
* no `sessionUrl` means "this server is not a broker" (`aai dev`, an older
|
|
32
|
+
* server) — a durable fact worth latching. A lookup that FAILED means
|
|
33
|
+
* nothing about the server, and treating the two alike is how a single 503
|
|
34
|
+
* (a sandbox mid-boot, or one that failed to start) pinned a session to the
|
|
35
|
+
* platform's `/:slug/websocket` — answered `410 Gone` on every retry, with
|
|
36
|
+
* no re-brokering even after the agent recovered.
|
|
37
|
+
*/
|
|
38
|
+
async function loadClientConfig(platformUrl, fetchFn) {
|
|
39
|
+
const doFetch = fetchFn ?? ((input, init) => globalThis.fetch(input, init));
|
|
40
|
+
try {
|
|
41
|
+
const resp = await doFetch(buildAgentUrl(platformUrl, CLIENT_CONFIG_PATH).href);
|
|
42
|
+
if (!resp.ok) return null;
|
|
43
|
+
const parsed = ClientConfigResponseSchema.safeParse(await resp.json());
|
|
44
|
+
return parsed.success ? parsed.data : null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Fetch the agent's client config; any failure yields the agent default. */
|
|
50
|
+
async function fetchClientConfig(platformUrl, fetchFn) {
|
|
51
|
+
return await loadClientConfig(platformUrl, fetchFn) ?? AGENT_DEFAULT;
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
5
54
|
//#region session-core-audio-setup.ts
|
|
6
55
|
/**
|
|
7
56
|
* Audio-path initialization for the voice session core.
|
|
@@ -29,8 +78,8 @@ import ReconnectingWebSocket from "partysocket/ws";
|
|
|
29
78
|
async function initAudioCapture(conn, msg, deps) {
|
|
30
79
|
if (conn.audioSetupInFlight) return;
|
|
31
80
|
conn.audioSetupInFlight = true;
|
|
32
|
-
const gen = conn.generation;
|
|
33
|
-
const stale = () => conn.generation
|
|
81
|
+
const gen = conn.generation.current();
|
|
82
|
+
const stale = () => !(conn.generation.isCurrent(gen) && conn.ws) || conn.ws.readyState !== WS_OPEN;
|
|
34
83
|
const reportAudioFailure = (message) => {
|
|
35
84
|
deps.cleanupAudio();
|
|
36
85
|
deps.updateState({
|
|
@@ -68,7 +117,7 @@ async function initAudioCapture(conn, msg, deps) {
|
|
|
68
117
|
console.warn("[aai-ui] microphone is delivering only silence — check the selected input device");
|
|
69
118
|
},
|
|
70
119
|
onError: (err) => {
|
|
71
|
-
if (conn.generation
|
|
120
|
+
if (!conn.generation.isCurrent(gen)) return;
|
|
72
121
|
reportAudioFailure(err.message);
|
|
73
122
|
}
|
|
74
123
|
});
|
|
@@ -92,7 +141,7 @@ async function initAudioCapture(conn, msg, deps) {
|
|
|
92
141
|
if (stale()) return;
|
|
93
142
|
reportAudioFailure(`Microphone access failed: ${errorMessage(err)}`);
|
|
94
143
|
} finally {
|
|
95
|
-
if (conn.generation
|
|
144
|
+
if (conn.generation.isCurrent(gen)) conn.audioSetupInFlight = false;
|
|
96
145
|
}
|
|
97
146
|
}
|
|
98
147
|
//#endregion
|
|
@@ -124,6 +173,7 @@ const CLEARED_SESSION_STATE = {
|
|
|
124
173
|
messages: [],
|
|
125
174
|
toolCalls: [],
|
|
126
175
|
customEvents: [],
|
|
176
|
+
agentState: null,
|
|
127
177
|
userTranscript: null,
|
|
128
178
|
agentTranscript: null,
|
|
129
179
|
error: null
|
|
@@ -137,14 +187,14 @@ function appendCapped(list, item, cap) {
|
|
|
137
187
|
/**
|
|
138
188
|
* Create the server→client message handlers for one session core.
|
|
139
189
|
*
|
|
140
|
-
* Encapsulates the two turn-boundary counters (`
|
|
190
|
+
* Encapsulates the two turn-boundary counters (`turnEpoch` for
|
|
141
191
|
* discarding stale async audio completions, `customEventSeq` for event
|
|
142
192
|
* dedup) that previously lived as closure locals in `createSessionCore`.
|
|
143
193
|
*/
|
|
144
194
|
function createMessageHandlers(deps) {
|
|
145
195
|
const { getSnapshot, updateState, conn, cleanupAudio } = deps;
|
|
146
|
-
/**
|
|
147
|
-
|
|
196
|
+
/** Bumped on each turn boundary -- stale async callbacks check against this. */
|
|
197
|
+
const turnEpoch = createEpoch();
|
|
148
198
|
/** Monotonically increasing counter for custom events -- used by useEvent to deduplicate. */
|
|
149
199
|
let customEventSeq = 0;
|
|
150
200
|
/** Monotonically increasing counter for chat messages -- stable render keys
|
|
@@ -161,7 +211,7 @@ function createMessageHandlers(deps) {
|
|
|
161
211
|
}, MAX_CUSTOM_EVENTS) });
|
|
162
212
|
}
|
|
163
213
|
function handleUserTranscriptEvent(text) {
|
|
164
|
-
|
|
214
|
+
turnEpoch.bump();
|
|
165
215
|
updateState({
|
|
166
216
|
userTranscript: null,
|
|
167
217
|
messages: appendCapped(getSnapshot().messages, {
|
|
@@ -221,7 +271,7 @@ function createMessageHandlers(deps) {
|
|
|
221
271
|
} });
|
|
222
272
|
else {
|
|
223
273
|
cleanupAudio();
|
|
224
|
-
conn.generation
|
|
274
|
+
conn.generation.bump();
|
|
225
275
|
updateState({
|
|
226
276
|
state: "error",
|
|
227
277
|
error: {
|
|
@@ -280,7 +330,7 @@ function createMessageHandlers(deps) {
|
|
|
280
330
|
updateState({ state: "listening" });
|
|
281
331
|
break;
|
|
282
332
|
case "cancelled":
|
|
283
|
-
|
|
333
|
+
turnEpoch.bump();
|
|
284
334
|
conn.voiceIO?.flush();
|
|
285
335
|
commitAgentTranscript();
|
|
286
336
|
updateState({
|
|
@@ -289,7 +339,7 @@ function createMessageHandlers(deps) {
|
|
|
289
339
|
});
|
|
290
340
|
break;
|
|
291
341
|
case "reset":
|
|
292
|
-
|
|
342
|
+
turnEpoch.bump();
|
|
293
343
|
conn.voiceIO?.flush();
|
|
294
344
|
updateState({
|
|
295
345
|
...CLEARED_SESSION_STATE,
|
|
@@ -299,6 +349,9 @@ function createMessageHandlers(deps) {
|
|
|
299
349
|
case "custom_event":
|
|
300
350
|
appendCustomEvent(e.event, e.data);
|
|
301
351
|
break;
|
|
352
|
+
case "agent_state":
|
|
353
|
+
updateState({ agentState: e.state });
|
|
354
|
+
break;
|
|
302
355
|
case "error":
|
|
303
356
|
handleErrorEvent(e);
|
|
304
357
|
break;
|
|
@@ -314,12 +367,12 @@ function createMessageHandlers(deps) {
|
|
|
314
367
|
else if (conn.preInitAudio.length < MAX_PREINIT_AUDIO_CHUNKS) conn.preInitAudio.push(chunk);
|
|
315
368
|
}
|
|
316
369
|
/** See {@link MessageHandlers.settleWhenAudioDrained}. Captures
|
|
317
|
-
* `
|
|
370
|
+
* `turnEpoch` so a completion (or failure) that lands after a turn
|
|
318
371
|
* boundary is discarded instead of overwriting the newer turn's state. */
|
|
319
372
|
function settleWhenAudioDrained(io) {
|
|
320
|
-
const gen =
|
|
373
|
+
const gen = turnEpoch.current();
|
|
321
374
|
io.done().then(() => {
|
|
322
|
-
if (
|
|
375
|
+
if (!turnEpoch.isCurrent(gen)) return;
|
|
323
376
|
updateState({ state: "listening" });
|
|
324
377
|
}).catch((err) => {
|
|
325
378
|
console.warn("Audio playback done failed:", err);
|
|
@@ -328,7 +381,7 @@ function createMessageHandlers(deps) {
|
|
|
328
381
|
/**
|
|
329
382
|
* Signal that the server has finished sending audio for this turn.
|
|
330
383
|
* Waits for the audio queue to drain, then transitions state to `"listening"`.
|
|
331
|
-
* Uses the `
|
|
384
|
+
* Uses the `turnEpoch` epoch to discard stale completions from interrupted turns.
|
|
332
385
|
*/
|
|
333
386
|
function playAudioDone() {
|
|
334
387
|
const io = conn.voiceIO;
|
|
@@ -395,8 +448,9 @@ const RECONNECT_OPTIONS = {
|
|
|
395
448
|
};
|
|
396
449
|
/**
|
|
397
450
|
* Open partysocket's reconnecting WebSocket. The URL is a *provider*,
|
|
398
|
-
* re-evaluated on every attempt, so each retry picks up
|
|
399
|
-
* URL rather than the
|
|
451
|
+
* re-evaluated on every attempt (async supported), so each retry picks up
|
|
452
|
+
* the current broker-named endpoint and resume URL rather than the ones the
|
|
453
|
+
* session started with.
|
|
400
454
|
*/
|
|
401
455
|
function openReconnectingSocket(urlProvider) {
|
|
402
456
|
return new ReconnectingWebSocket(urlProvider, void 0, RECONNECT_OPTIONS);
|
|
@@ -414,8 +468,21 @@ function reconnectPending(socket) {
|
|
|
414
468
|
//#region session-core-url.ts
|
|
415
469
|
/** Build the session WebSocket URL from the platform URL and resume state. */
|
|
416
470
|
function buildWsUrl(platformUrl, resume, sessionId) {
|
|
417
|
-
|
|
418
|
-
|
|
471
|
+
return applyResumeParams(new URL("websocket", platformUrl.endsWith("/") ? platformUrl : `${platformUrl}/`), resume, sessionId);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Turn a broker-provided session URL (`sessionUrl` from `GET client-config`
|
|
475
|
+
* — the agent's live sandbox endpoint) into this attempt's connect URL.
|
|
476
|
+
*/
|
|
477
|
+
function buildBrokeredWsUrl(sessionUrl, resume, sessionId) {
|
|
478
|
+
return applyResumeParams(new URL(sessionUrl), resume, sessionId);
|
|
479
|
+
}
|
|
480
|
+
const WS_PROTOCOLS = {
|
|
481
|
+
"https:": "wss:",
|
|
482
|
+
"http:": "ws:"
|
|
483
|
+
};
|
|
484
|
+
function applyResumeParams(wsUrl, resume, sessionId) {
|
|
485
|
+
wsUrl.protocol = WS_PROTOCOLS[wsUrl.protocol] ?? wsUrl.protocol;
|
|
419
486
|
if (sessionId) wsUrl.searchParams.set("sessionId", sessionId);
|
|
420
487
|
else if (resume) wsUrl.searchParams.set("resume", "1");
|
|
421
488
|
return wsUrl;
|
|
@@ -491,7 +558,7 @@ function createSessionCore(options) {
|
|
|
491
558
|
ws: null,
|
|
492
559
|
voiceIO: null,
|
|
493
560
|
audioSetupInFlight: false,
|
|
494
|
-
generation:
|
|
561
|
+
generation: createEpoch(),
|
|
495
562
|
preInitAudio: [],
|
|
496
563
|
preInitDone: false
|
|
497
564
|
};
|
|
@@ -506,6 +573,15 @@ function createSessionCore(options) {
|
|
|
506
573
|
* agent's context, greeting suppression aside.
|
|
507
574
|
*/
|
|
508
575
|
let sessionId = options.resumeSessionId;
|
|
576
|
+
/**
|
|
577
|
+
* Whether `platformUrl` is a broker (its `client-config` names a
|
|
578
|
+
* `sessionUrl`). A server is one or it isn't — it never flips mid-session
|
|
579
|
+
* — so once a non-broker is observed, later reconnects skip the
|
|
580
|
+
* `client-config` re-fetch that would only fall through to `buildWsUrl`
|
|
581
|
+
* (every reconnect on `aai dev` / self-hosted otherwise pays a wasted GET).
|
|
582
|
+
* `undefined` until the first fetch settles.
|
|
583
|
+
*/
|
|
584
|
+
let serverIsBroker;
|
|
509
585
|
function cleanupAudio() {
|
|
510
586
|
conn.audioSetupInFlight = false;
|
|
511
587
|
conn.voiceIO?.close().catch(() => {});
|
|
@@ -565,19 +641,34 @@ function createSessionCore(options) {
|
|
|
565
641
|
}
|
|
566
642
|
/**
|
|
567
643
|
* The WebSocket URL for the *next* connection attempt. Evaluated per
|
|
568
|
-
* attempt (partysocket takes it as
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
644
|
+
* attempt (partysocket takes it as an async URL provider):
|
|
645
|
+
*
|
|
646
|
+
* - `GET client-config` is re-fetched every attempt. When it names a
|
|
647
|
+
* `sessionUrl` — the platform's broker pointing at the agent's live
|
|
648
|
+
* sandbox — the session connects DIRECTLY there. The URL changes when
|
|
649
|
+
* the sandbox is replaced (idle eviction, redeploy), which is exactly
|
|
650
|
+
* when a reconnect happens, so per-attempt brokering is what makes
|
|
651
|
+
* reconnects land on the replacement. Without one (`aai dev`, older
|
|
652
|
+
* servers), the same-origin `websocket` path is used.
|
|
653
|
+
* - Once the first `config` arrives, every reconnect carries
|
|
654
|
+
* `?sessionId=<id>` and the server resumes the SAME session (id, tool
|
|
655
|
+
* state) instead of minting a new one. `resume=1` remains only as the
|
|
656
|
+
* greeting-suppression fallback for a server whose config carried no id.
|
|
573
657
|
*/
|
|
574
|
-
function currentWsUrl() {
|
|
575
|
-
|
|
658
|
+
async function currentWsUrl() {
|
|
659
|
+
const cfg = serverIsBroker === false ? null : await loadClientConfig(options.platformUrl);
|
|
660
|
+
if (cfg) serverIsBroker = cfg.sessionUrl !== void 0;
|
|
661
|
+
const url = cfg?.sessionUrl ? buildBrokeredWsUrl(cfg.sessionUrl, hasConnected, sessionId) : buildWsUrl(options.platformUrl, hasConnected, sessionId);
|
|
662
|
+
const display = new URL(url);
|
|
663
|
+
display.search = "";
|
|
664
|
+
if (display.toString() !== currentSnapshot.apiUrl) updateState({ apiUrl: display.toString() });
|
|
665
|
+
return url.toString();
|
|
576
666
|
}
|
|
577
|
-
/** Open a socket: an injected constructor as-is (tests
|
|
578
|
-
*
|
|
667
|
+
/** Open a socket: an injected constructor as-is (tests — connects to the
|
|
668
|
+
* same-origin path, no brokering), or partysocket's reconnecting
|
|
669
|
+
* WebSocket — same interface, plus reconnect-on-close. */
|
|
579
670
|
function openSocket() {
|
|
580
|
-
if (options.WebSocket) return new options.WebSocket(
|
|
671
|
+
if (options.WebSocket) return new options.WebSocket(buildWsUrl(options.platformUrl, hasConnected, sessionId).toString());
|
|
581
672
|
return openReconnectingSocket(currentWsUrl);
|
|
582
673
|
}
|
|
583
674
|
function connect(opts) {
|
|
@@ -590,7 +681,7 @@ function createSessionCore(options) {
|
|
|
590
681
|
error: null
|
|
591
682
|
});
|
|
592
683
|
teardownConnection();
|
|
593
|
-
conn.generation
|
|
684
|
+
conn.generation.bump();
|
|
594
685
|
const controller = new AbortController();
|
|
595
686
|
connectionController = controller;
|
|
596
687
|
const { signal: sig } = controller;
|
|
@@ -613,7 +704,7 @@ function createSessionCore(options) {
|
|
|
613
704
|
if (sig.aborted) return;
|
|
614
705
|
cleanupAudio();
|
|
615
706
|
if (reconnectPending(socket)) {
|
|
616
|
-
conn.generation
|
|
707
|
+
conn.generation.bump();
|
|
617
708
|
socketErrored = false;
|
|
618
709
|
updateState({
|
|
619
710
|
state: "connecting",
|
|
@@ -700,4 +791,4 @@ function createSessionCore(options) {
|
|
|
700
791
|
};
|
|
701
792
|
}
|
|
702
793
|
//#endregion
|
|
703
|
-
export { createSessionCore as t };
|
|
794
|
+
export { buildAgentUrl as n, fetchClientConfig as r, createSessionCore as t };
|
|
@@ -9,6 +9,7 @@ export declare const CLEARED_SESSION_STATE: {
|
|
|
9
9
|
messages: never[];
|
|
10
10
|
toolCalls: never[];
|
|
11
11
|
customEvents: never[];
|
|
12
|
+
agentState: null;
|
|
12
13
|
userTranscript: null;
|
|
13
14
|
agentTranscript: null;
|
|
14
15
|
error: null;
|
|
@@ -50,7 +51,7 @@ type MessageHandlers = {
|
|
|
50
51
|
/**
|
|
51
52
|
* Create the server→client message handlers for one session core.
|
|
52
53
|
*
|
|
53
|
-
* Encapsulates the two turn-boundary counters (`
|
|
54
|
+
* Encapsulates the two turn-boundary counters (`turnEpoch` for
|
|
54
55
|
* discarding stale async audio completions, `customEventSeq` for event
|
|
55
56
|
* dedup) that previously lived as closure locals in `createSessionCore`.
|
|
56
57
|
*/
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
import ReconnectingWebSocket from "partysocket/ws";
|
|
7
7
|
/**
|
|
8
8
|
* Open partysocket's reconnecting WebSocket. The URL is a *provider*,
|
|
9
|
-
* re-evaluated on every attempt, so each retry picks up
|
|
10
|
-
* URL rather than the
|
|
9
|
+
* re-evaluated on every attempt (async supported), so each retry picks up
|
|
10
|
+
* the current broker-named endpoint and resume URL rather than the ones the
|
|
11
|
+
* session started with.
|
|
11
12
|
*/
|
|
12
|
-
export declare function openReconnectingSocket(urlProvider: () => string): ReconnectingWebSocket;
|
|
13
|
+
export declare function openReconnectingSocket(urlProvider: () => Promise<string>): ReconnectingWebSocket;
|
|
13
14
|
/**
|
|
14
15
|
* True while `socket` is a reconnecting socket that will retry after the
|
|
15
16
|
* close event currently being handled. partysocket schedules the retry
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Split out of `session-core.ts` to keep that module focused on behaviour.
|
|
5
5
|
*/
|
|
6
|
+
import type { Epoch } from "@alexkroman1/aai";
|
|
6
7
|
import type { VoiceIO } from "./audio.ts";
|
|
7
8
|
import type { AgentState, ChatMessage, SessionError, ToolCallInfo, VoiceSessionOptions, WebSocketConstructor } from "./types.ts";
|
|
8
9
|
/**
|
|
@@ -43,6 +44,12 @@ export type SessionSnapshot = {
|
|
|
43
44
|
readonly messages: ChatMessage[];
|
|
44
45
|
readonly toolCalls: ToolCallInfo[];
|
|
45
46
|
readonly customEvents: CustomEvent[];
|
|
47
|
+
/**
|
|
48
|
+
* Latest state the agent projected via `syncState`, or `null` before the
|
|
49
|
+
* first push. A value, not a log — a component that mounts mid-session
|
|
50
|
+
* reads current state rather than replaying events it missed.
|
|
51
|
+
*/
|
|
52
|
+
readonly agentState: unknown;
|
|
46
53
|
readonly userTranscript: string | null;
|
|
47
54
|
readonly agentTranscript: string | null;
|
|
48
55
|
readonly error: SessionError | null;
|
|
@@ -97,9 +104,10 @@ export type ConnState = {
|
|
|
97
104
|
ws: InstanceType<WebSocketConstructor> | null;
|
|
98
105
|
voiceIO: VoiceIO | null;
|
|
99
106
|
audioSetupInFlight: boolean;
|
|
100
|
-
/**
|
|
101
|
-
* initAudioCapture from assigning its voiceIO to a newer
|
|
102
|
-
|
|
107
|
+
/** Connection epoch, bumped on each connect()/retry (see `createEpoch`).
|
|
108
|
+
* Prevents a stale initAudioCapture from assigning its voiceIO to a newer
|
|
109
|
+
* connection. */
|
|
110
|
+
generation: Epoch;
|
|
103
111
|
/** Audio chunks that arrived before `voiceIO` was initialized — drained into
|
|
104
112
|
* the playback worklet once init completes. Closes the race between the
|
|
105
113
|
* server starting greeting audio (immediately on S2S connect) and the
|
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
/** Build the session WebSocket URL from the platform URL and resume state. */
|
|
2
2
|
export declare function buildWsUrl(platformUrl: string, resume: boolean, sessionId?: string): URL;
|
|
3
|
+
/**
|
|
4
|
+
* Turn a broker-provided session URL (`sessionUrl` from `GET client-config`
|
|
5
|
+
* — the agent's live sandbox endpoint) into this attempt's connect URL.
|
|
6
|
+
*/
|
|
7
|
+
export declare function buildBrokeredWsUrl(sessionUrl: string, resume: boolean, sessionId?: string): URL;
|