@alexkroman1/aai-ui 6.11.0 → 7.0.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 +104 -3
- package/dist/_run-controls.d.ts +33 -0
- package/dist/{chat-view-ByQFf94G.js → chat-view-BKsFfFZJ.js} +57 -11
- package/dist/client-config-B4nznRvH.js +134 -0
- package/dist/client-config.d.ts +45 -2
- package/dist/client-dir.d.ts +3 -1
- package/dist/client-dir.js +3 -1
- package/dist/components/auto-scroll.d.ts +24 -13
- package/dist/components/button.d.ts +8 -4
- package/dist/components/button.js +4 -4
- package/dist/components/chat-view.d.ts +4 -3
- package/dist/components/chat-view.js +1 -1
- package/dist/components/console-shell.d.ts +71 -10
- package/dist/components/controls.d.ts +15 -4
- package/dist/components/controls.js +48 -2
- package/dist/components/form-fields.d.ts +142 -0
- package/dist/components/form-types.d.ts +6 -0
- package/dist/components/form.d.ts +38 -80
- package/dist/components/markdown.d.ts +31 -5
- package/dist/components/message-list.d.ts +21 -4
- package/dist/components/message-list.js +1 -1
- package/dist/components/sidebar-layout.d.ts +11 -0
- package/dist/components/sidebar-layout.js +2 -0
- package/dist/components/start-screen.d.ts +8 -0
- package/dist/components/start-screen.js +2 -0
- package/dist/components/tool-call-block.js +1 -1
- package/dist/components/tool-call-row.d.ts +24 -0
- package/dist/components/upload-progress.d.ts +17 -7
- package/dist/components/workflow-fields.d.ts +10 -22
- package/dist/components/workflow-progress.d.ts +29 -10
- package/dist/context.d.ts +36 -0
- package/dist/context.js +105 -14
- package/dist/default-client/assets/index-S5fkKi6B.css +2 -0
- package/dist/default-client/assets/index-fEkrcZgo.js +293 -0
- package/dist/default-client/index.html +2 -2
- package/dist/define-client.d.ts +67 -62
- package/dist/define-client.js +56 -22
- package/dist/hooks.d.ts +88 -3
- package/dist/index.d.ts +12 -11
- package/dist/index.js +501 -167
- package/dist/internal.d.ts +40 -0
- package/dist/internal.js +6 -0
- package/dist/{message-list-CpPV7dGx.js → message-list-DHddO4QC.js} +217 -72
- package/dist/{session-core-CAfYmUbg.js → session-core-C2JtLArh.js} +267 -165
- package/dist/session-core-audio-setup.d.ts +3 -0
- package/dist/session-core-messages.d.ts +3 -0
- package/dist/session-core-state.d.ts +146 -0
- package/dist/session-core-types.d.ts +77 -32
- package/dist/session-core.d.ts +3 -2
- package/dist/session-core.js +1 -1
- package/dist/{tool-call-block-D6pTEPrT.js → tool-call-block-DoF-cSIZ.js} +27 -19
- package/dist/tool-config-context-DzAofqi_.js +19 -0
- package/dist/types.d.ts +31 -5
- package/dist/types.js +5 -4
- package/dist/{controls-CjG91QJ4.js → url-chips-DpM7Oocj.js} +3 -46
- package/dist/use-conversation.d.ts +122 -0
- package/dist/use-download-url.d.ts +83 -0
- package/dist/use-workflow-form.d.ts +59 -3
- package/dist/use-workflow-run.d.ts +35 -0
- package/dist/use-workflow-stream.d.ts +36 -62
- package/dist/workflow-client.d.ts +36 -11
- package/dist/workflow-status-labels.d.ts +35 -0
- package/package.json +10 -5
- package/styles.css +14 -0
- package/dist/default-client/assets/index-DTLrhtTF.css +0 -2
- package/dist/default-client/assets/index-DXODx_9r.js +0 -293
|
@@ -1,98 +1,11 @@
|
|
|
1
|
+
import { r as loadClientConfig, t as buildAgentUrl } from "./client-config-B4nznRvH.js";
|
|
1
2
|
import { MIC_SEND_MAX_BUFFERED_BYTES } from "./types.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { omitUndefined
|
|
5
|
-
import { WS_OPEN, createEpoch } from "@alexkroman1/aai/internal";
|
|
3
|
+
import { ServerMessageSchema, lenientParse } from "@alexkroman1/aai/protocol";
|
|
4
|
+
import { errorMessage, safeJsonParse } from "@alexkroman1/aai";
|
|
5
|
+
import { omitUndefined } from "@alexkroman1/aai/utils";
|
|
6
|
+
import { DEFAULT_MAX_HISTORY, WS_OPEN, createEpoch, toArgsRecord } from "@alexkroman1/aai/internal";
|
|
6
7
|
import ReconnectingWebSocket from "partysocket/ws";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Pre-connection client-config lookup.
|
|
10
|
-
*
|
|
11
|
-
* `GET client-config` (relative to the agent's base URL — see
|
|
12
|
-
* `sdk/client-config.ts` in `@alexkroman1/aai`) gives the default client the
|
|
13
|
-
* agent's display name and greeting before any connection exists. For that
|
|
14
|
-
* use every failure path — network error, 404 from an older server,
|
|
15
|
-
* malformed body — degrades to the empty default (`fetchClientConfig`), so
|
|
16
|
-
* the lookup can never break an existing agent.
|
|
17
|
-
*
|
|
18
|
-
* The session's broker decision needs the opposite: `loadClientConfig`
|
|
19
|
-
* keeps "the lookup failed" (`null`) distinct from "the server answered and
|
|
20
|
-
* named no sessionUrl" (`{}`). See its doc comment.
|
|
21
|
-
*/
|
|
22
|
-
/**
|
|
23
|
-
* Resolve a relative endpoint path against the agent's base URL.
|
|
24
|
-
*
|
|
25
|
-
* @internal
|
|
26
|
-
*/
|
|
27
|
-
function buildAgentUrl(platformUrl, endpointPath) {
|
|
28
|
-
return new URL(endpointPath, platformUrl.endsWith("/") ? platformUrl : `${platformUrl}/`);
|
|
29
|
-
}
|
|
30
|
-
const AGENT_DEFAULT = {};
|
|
31
|
-
/**
|
|
32
|
-
* Per-attempt deadline for the `client-config` lookup.
|
|
33
|
-
*
|
|
34
|
-
* A request issued while the platform is restarting or saturated can HANG
|
|
35
|
-
* rather than fail — the proxy holds the socket open — and a browser fetch
|
|
36
|
-
* has no timeout of its own. Every other failure here is already handled
|
|
37
|
-
* (`null`, then the same-origin fallback), but a hang is not a failure: the
|
|
38
|
-
* promise simply never settles.
|
|
39
|
-
*
|
|
40
|
-
* That is unrecoverable rather than merely slow, because this lookup runs
|
|
41
|
-
* inside the session's WebSocket URL *provider*. partysocket awaits the
|
|
42
|
-
* provider under `_connectLock` and arms its own `connectionTimeout` only
|
|
43
|
-
* AFTER the URL resolves, so a hung lookup means no socket is ever
|
|
44
|
-
* constructed, no `error`/`close` ever fires, and none of the 10 reconnect
|
|
45
|
-
* attempts ever happen — the session sits on "connecting" forever, and stays
|
|
46
|
-
* there long after the server is back. Reproduced: zero sockets opened.
|
|
47
|
-
*
|
|
48
|
-
* A timed-out attempt therefore degrades exactly like any other failed one —
|
|
49
|
-
* `null`, so `serverIsBroker` stays unlatched and the attempt falls through
|
|
50
|
-
* to the same-origin `websocket` path, whose failure re-enters the normal
|
|
51
|
-
* backoff and re-fetches this on the next attempt.
|
|
52
|
-
*
|
|
53
|
-
* Sized well above the real work (one same-origin JSON GET that reads the
|
|
54
|
-
* agent's row) and well under a user's patience — the same 10s the studio's
|
|
55
|
-
* gating reads use for the identical hazard.
|
|
56
|
-
*
|
|
57
|
-
* @internal
|
|
58
|
-
*/
|
|
59
|
-
const CLIENT_CONFIG_ATTEMPT_TIMEOUT_MS = 1e4;
|
|
60
|
-
/**
|
|
61
|
-
* Fetch the agent's client config, reporting `null` when the lookup did not
|
|
62
|
-
* produce an answer (network error, non-2xx, unparsable body).
|
|
63
|
-
*
|
|
64
|
-
* The distinction from `fetchClientConfig` matters for exactly one caller:
|
|
65
|
-
* the session's per-attempt broker decision. A config that ARRIVED and named
|
|
66
|
-
* no `sessionUrl` means "this server is not a broker" (`aai dev`, an older
|
|
67
|
-
* server) — a durable fact worth latching. A lookup that FAILED means
|
|
68
|
-
* nothing about the server, and treating the two alike is how a single 503
|
|
69
|
-
* (a sandbox mid-boot, or one that failed to start) pinned a session to the
|
|
70
|
-
* platform's `/:slug/websocket` — a WebSocket redirect browsers don't
|
|
71
|
-
* follow, so every retry failed with no re-brokering even after the agent
|
|
72
|
-
* recovered.
|
|
73
|
-
*
|
|
74
|
-
* @internal
|
|
75
|
-
*/
|
|
76
|
-
async function loadClientConfig(platformUrl, fetchFn) {
|
|
77
|
-
const doFetch = fetchFn ?? ((input, init) => globalThis.fetch(input, init));
|
|
78
|
-
try {
|
|
79
|
-
const resp = await doFetch(buildAgentUrl(platformUrl, CLIENT_CONFIG_PATH).href, { signal: AbortSignal.timeout(CLIENT_CONFIG_ATTEMPT_TIMEOUT_MS) });
|
|
80
|
-
if (!resp.ok) return null;
|
|
81
|
-
const parsed = ClientConfigResponseSchema.safeParse(await resp.json());
|
|
82
|
-
return parsed.success ? parsed.data : null;
|
|
83
|
-
} catch {
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Fetch the agent's client config; any failure yields the agent default.
|
|
89
|
-
*
|
|
90
|
-
* @internal
|
|
91
|
-
*/
|
|
92
|
-
async function fetchClientConfig(platformUrl, fetchFn) {
|
|
93
|
-
return await loadClientConfig(platformUrl, fetchFn) ?? AGENT_DEFAULT;
|
|
94
|
-
}
|
|
95
|
-
//#endregion
|
|
8
|
+
import { and, assign, createActor, not, setup, stateIn } from "xstate";
|
|
96
9
|
//#region session-core-audio-setup.ts
|
|
97
10
|
/**
|
|
98
11
|
* Audio-path initialization for the voice session core.
|
|
@@ -143,11 +56,13 @@ async function initAudioCapture(conn, msg, deps) {
|
|
|
143
56
|
const reportAudioFailure = (message) => {
|
|
144
57
|
deps.cleanupAudio();
|
|
145
58
|
deps.updateState({
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
59
|
+
...deps.agentState.apply({
|
|
60
|
+
type: "FAILED",
|
|
61
|
+
error: {
|
|
62
|
+
code: "audio",
|
|
63
|
+
message
|
|
64
|
+
}
|
|
65
|
+
}),
|
|
151
66
|
running: false,
|
|
152
67
|
recording: false
|
|
153
68
|
});
|
|
@@ -195,7 +110,7 @@ async function initAudioCapture(conn, msg, deps) {
|
|
|
195
110
|
if (conn.preInitDone) {
|
|
196
111
|
conn.preInitDone = false;
|
|
197
112
|
deps.settleWhenAudioDrained(io);
|
|
198
|
-
} else deps.updateState({
|
|
113
|
+
} else deps.updateState(deps.agentState.apply({ type: "LISTEN" }));
|
|
199
114
|
} catch (err) {
|
|
200
115
|
if (stale()) return;
|
|
201
116
|
reportAudioFailure(`Microphone access failed: ${errorMessage(err)}`);
|
|
@@ -556,7 +471,7 @@ function appendCapped(list, item, cap) {
|
|
|
556
471
|
* `ConnState.turn`).
|
|
557
472
|
*/
|
|
558
473
|
function createMessageHandlers(deps) {
|
|
559
|
-
const { getSnapshot, updateState, conn, cleanupAudio } = deps;
|
|
474
|
+
const { getSnapshot, updateState, conn, agentState, cleanupAudio } = deps;
|
|
560
475
|
/** Monotonically increasing counter for custom events -- used by useEvent to deduplicate. */
|
|
561
476
|
let customEventSeq = 0;
|
|
562
477
|
/** Monotonically increasing counter for chat messages -- stable render keys
|
|
@@ -581,7 +496,7 @@ function createMessageHandlers(deps) {
|
|
|
581
496
|
role: "user",
|
|
582
497
|
content: text
|
|
583
498
|
}, MAX_MESSAGES),
|
|
584
|
-
|
|
499
|
+
...agentState.apply({ type: "THINK" })
|
|
585
500
|
});
|
|
586
501
|
}
|
|
587
502
|
/**
|
|
@@ -622,54 +537,53 @@ function createMessageHandlers(deps) {
|
|
|
622
537
|
/** Clear error state when a non-error event arrives — proves the session
|
|
623
538
|
* is functional (e.g. audio init failed but WebSocket still works).
|
|
624
539
|
*
|
|
625
|
-
* A FATAL
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
540
|
+
* A FATAL session is exempt, and that exemption is the whole reason the
|
|
541
|
+
* `fatal` region exists: the host's teardown emits, so the frames that
|
|
542
|
+
* follow a fatal error are a consequence of it rather than evidence
|
|
543
|
+
* against it. Recovering on them left the one message that says what to
|
|
544
|
+
* fix — a missing provider key — on screen for a fraction of a second,
|
|
545
|
+
* over a session that could no longer hear anyone. */
|
|
631
546
|
function clearRecoveredError() {
|
|
632
|
-
|
|
633
|
-
const snap = getSnapshot();
|
|
634
|
-
if (snap.state === "error") updateState({
|
|
635
|
-
state: "listening",
|
|
636
|
-
error: null
|
|
637
|
-
});
|
|
638
|
-
else if (snap.error !== null) updateState({ error: null });
|
|
547
|
+
updateState(agentState.apply({ type: "ACTIVITY" }));
|
|
639
548
|
}
|
|
640
549
|
/**
|
|
641
550
|
* Return to "listening" at a turn boundary — unless the session is over.
|
|
642
551
|
*
|
|
643
552
|
* `reply.completed`, `reply.cancelled` and `session.reset` each wrote
|
|
644
|
-
* `state: "listening"`
|
|
645
|
-
*
|
|
646
|
-
* `clearRecoveredError`'s latch covers: the host's fatal paths all call
|
|
553
|
+
* `state: "listening"` unconditionally, which is the second half of the same
|
|
554
|
+
* bug `clearRecoveredError` covers: the host's fatal paths all call
|
|
647
555
|
* `terminate()`, and terminating emits `onCancelled()`. So the frame that
|
|
648
556
|
* ANNOUNCES the session's death was also the frame that painted a live-mic
|
|
649
557
|
* state over the error it had just reported.
|
|
558
|
+
*
|
|
559
|
+
* The exemption is not restated here: `LISTEN` is declined while the `fatal`
|
|
560
|
+
* region says so, which is what makes this the whole of the rule rather than
|
|
561
|
+
* one of four sites that had to remember it.
|
|
650
562
|
*/
|
|
651
563
|
function toListening(extra = {}) {
|
|
652
|
-
updateState(
|
|
564
|
+
updateState({
|
|
653
565
|
...extra,
|
|
654
|
-
|
|
566
|
+
...agentState.apply({ type: "LISTEN" })
|
|
655
567
|
});
|
|
656
568
|
}
|
|
657
569
|
function handleErrorEvent(e) {
|
|
658
570
|
console.error("Agent error:", e.message);
|
|
659
|
-
|
|
571
|
+
const error = {
|
|
660
572
|
code: e.code,
|
|
661
573
|
message: e.message
|
|
662
|
-
}
|
|
574
|
+
};
|
|
575
|
+
if (e.fatal === false) updateState(agentState.apply({
|
|
576
|
+
type: "TURN_ERROR",
|
|
577
|
+
error
|
|
578
|
+
}));
|
|
663
579
|
else {
|
|
664
580
|
cleanupAudio();
|
|
665
581
|
conn.generation.bump();
|
|
666
|
-
conn.fatalError = true;
|
|
667
582
|
updateState({
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
},
|
|
583
|
+
...agentState.apply({
|
|
584
|
+
type: "FATAL",
|
|
585
|
+
error
|
|
586
|
+
}),
|
|
673
587
|
running: false,
|
|
674
588
|
recording: false
|
|
675
589
|
});
|
|
@@ -727,11 +641,16 @@ function createMessageHandlers(deps) {
|
|
|
727
641
|
commitAgentTranscript();
|
|
728
642
|
toListening({ userTranscript: null });
|
|
729
643
|
break;
|
|
730
|
-
case "session.reset":
|
|
644
|
+
case "session.reset": {
|
|
731
645
|
conn.turn.bump();
|
|
732
646
|
conn.voiceIO?.flush();
|
|
733
|
-
|
|
647
|
+
const next = agentState.apply({ type: "RESET" });
|
|
648
|
+
updateState(agentState.fatal() ? next : {
|
|
649
|
+
...CLEARED_SESSION_STATE,
|
|
650
|
+
...next
|
|
651
|
+
});
|
|
734
652
|
break;
|
|
653
|
+
}
|
|
735
654
|
case "custom.emitted":
|
|
736
655
|
appendCustomEvent(e.event, e.data);
|
|
737
656
|
break;
|
|
@@ -771,9 +690,7 @@ function createMessageHandlers(deps) {
|
|
|
771
690
|
}
|
|
772
691
|
/** Enqueue a PCM16 audio chunk for playback. Transitions state to `"speaking"` on the first chunk. */
|
|
773
692
|
function playAudioChunk(chunk) {
|
|
774
|
-
|
|
775
|
-
if (snap.state === "error" || snap.state === "disconnected" && snap.error !== null) return;
|
|
776
|
-
if (snap.state !== "speaking") updateState({ state: "speaking" });
|
|
693
|
+
updateState(agentState.apply({ type: "SPEAK" }));
|
|
777
694
|
if (conn.voiceIO) conn.voiceIO.enqueue(chunk);
|
|
778
695
|
else if (conn.preInitAudio.length < MAX_PREINIT_AUDIO_CHUNKS) conn.preInitAudio.push(chunk);
|
|
779
696
|
}
|
|
@@ -785,7 +702,7 @@ function createMessageHandlers(deps) {
|
|
|
785
702
|
const gen = conn.turn.current();
|
|
786
703
|
io.done().then(() => {
|
|
787
704
|
if (!conn.turn.isCurrent(gen)) return;
|
|
788
|
-
updateState({
|
|
705
|
+
updateState(agentState.apply({ type: "LISTEN" }));
|
|
789
706
|
}).catch((err) => {
|
|
790
707
|
console.warn("Audio playback done failed:", err);
|
|
791
708
|
});
|
|
@@ -800,7 +717,7 @@ function createMessageHandlers(deps) {
|
|
|
800
717
|
if (io) settleWhenAudioDrained(io);
|
|
801
718
|
else {
|
|
802
719
|
conn.preInitDone = true;
|
|
803
|
-
updateState({
|
|
720
|
+
updateState(agentState.apply({ type: "LISTEN" }));
|
|
804
721
|
}
|
|
805
722
|
}
|
|
806
723
|
function handleMessage(data) {
|
|
@@ -824,7 +741,7 @@ function createMessageHandlers(deps) {
|
|
|
824
741
|
}
|
|
825
742
|
const msg = parsed.data;
|
|
826
743
|
if (msg.type === "session.configured") {
|
|
827
|
-
|
|
744
|
+
agentState.apply({ type: "HANDSHAKE_COMPLETE" });
|
|
828
745
|
return {
|
|
829
746
|
sampleRate: msg.sampleRate,
|
|
830
747
|
ttsSampleRate: msg.ttsSampleRate,
|
|
@@ -843,6 +760,188 @@ function createMessageHandlers(deps) {
|
|
|
843
760
|
};
|
|
844
761
|
}
|
|
845
762
|
//#endregion
|
|
763
|
+
//#region session-core-state.ts
|
|
764
|
+
/**
|
|
765
|
+
* The browser session's {@link AgentState}, and the error beside it, as a
|
|
766
|
+
* statechart.
|
|
767
|
+
*
|
|
768
|
+
* These two were written independently from thirteen sites across
|
|
769
|
+
* `session-core.ts`, `session-core-messages.ts` and
|
|
770
|
+
* `session-core-audio-setup.ts`, each one deciding for itself whether its write
|
|
771
|
+
* was legal by reading the snapshot back first. Three shipped bugs came out of
|
|
772
|
+
* that, and all three are the same shape — a transition nothing forbade:
|
|
773
|
+
*
|
|
774
|
+
* - **A straggler audio chunk flipped an errored session to `"speaking"`.**
|
|
775
|
+
* Guarded by hand at the call site
|
|
776
|
+
* (`if (snap.state === "error" || (snap.state === "disconnected" && …))`).
|
|
777
|
+
* Here `error` simply does not handle `SPEAK`.
|
|
778
|
+
* - **The frame that announced a session's death also wiped the banner
|
|
779
|
+
* reporting it.** Every fatal path in the host tears the transport down, and
|
|
780
|
+
* tearing down EMITS — so `reply.cancelled` arrived right behind the error and
|
|
781
|
+
* `toListening()` painted a live-mic state over it. A missing provider key is
|
|
782
|
+
* the case that made it visible: the one message that says exactly what to fix
|
|
783
|
+
* was on screen for a few hundred milliseconds and left a session that looked
|
|
784
|
+
* live and was deaf.
|
|
785
|
+
* - **A later frame RECOVERED the state.** `clearRecoveredError` reads a
|
|
786
|
+
* non-error frame as proof the session works, which is right for a
|
|
787
|
+
* turn-level failure and wrong for a server error that ended the call.
|
|
788
|
+
*
|
|
789
|
+
* The second and third were fixed with a `conn.fatalError` boolean that every
|
|
790
|
+
* writer had to remember to consult. It is the `fatal` region here, so
|
|
791
|
+
* forgetting is not available: `LISTEN` and `ACTIVITY` are declined in one
|
|
792
|
+
* place rather than at each of their five call sites.
|
|
793
|
+
*
|
|
794
|
+
* ## Two regions, because the fatal latch OUTLIVES the error state
|
|
795
|
+
*
|
|
796
|
+
* `fatal` is not a substate of `error`, tempting as that looks. It is cleared by
|
|
797
|
+
* exactly one thing — the next `config` frame, i.e. a completed handshake — and
|
|
798
|
+
* that is per CONNECTION rather than per session, so a reconnect that really
|
|
799
|
+
* works is not pinned to a dead session's banner. Between the error and that
|
|
800
|
+
* frame the phase runs `error → connecting → ready` while the latch stays set,
|
|
801
|
+
* which a substate cannot express.
|
|
802
|
+
*
|
|
803
|
+
* ## The published surface is unchanged
|
|
804
|
+
*
|
|
805
|
+
* {@link AgentState} is a public type in the versioned `aai-ui:session`
|
|
806
|
+
* capability, so {@link AgentStateSnapshot.state} is one of its seven names and
|
|
807
|
+
* nothing here widens it. The internal distinctions live in the second region
|
|
808
|
+
* and in context, not in the projection.
|
|
809
|
+
*/
|
|
810
|
+
/**
|
|
811
|
+
* Not fatally over — so a working state may be painted.
|
|
812
|
+
*
|
|
813
|
+
* `stateIn` reads the sibling REGION rather than a mirror of it in context,
|
|
814
|
+
* which is what keeps `fatal` the one place the latch lives. The three events
|
|
815
|
+
* this guards are the three that used to consult `conn.fatalError` by hand at
|
|
816
|
+
* five call sites; `THINK` is a fourth that did not, and should have — the doc
|
|
817
|
+
* on that flag says outright that "no later frame may take its banner off the
|
|
818
|
+
* screen", and a `user-transcript.committed` arriving behind a fatal error
|
|
819
|
+
* painted `"thinking"` over it.
|
|
820
|
+
*/
|
|
821
|
+
const NOT_FATAL = not(stateIn({ fatal: "yes" }));
|
|
822
|
+
const sessionStateMachine = setup({
|
|
823
|
+
types: {},
|
|
824
|
+
guards: {
|
|
825
|
+
/** A banner is still up from a failure the session survived. */
|
|
826
|
+
hasError: ({ context }) => context.error !== null },
|
|
827
|
+
actions: {
|
|
828
|
+
clearError: assign({ error: null }),
|
|
829
|
+
setError: assign({ error: ({ context, event }) => event.type === "TURN_ERROR" || event.type === "FATAL" || event.type === "FAILED" ? event.error : context.error })
|
|
830
|
+
}
|
|
831
|
+
}).createMachine({
|
|
832
|
+
id: "sessionState",
|
|
833
|
+
type: "parallel",
|
|
834
|
+
context: { error: null },
|
|
835
|
+
states: {
|
|
836
|
+
/** The seven names {@link AgentState} publishes. */
|
|
837
|
+
phase: {
|
|
838
|
+
initial: "disconnected",
|
|
839
|
+
on: {
|
|
840
|
+
CONNECT: {
|
|
841
|
+
target: ".connecting",
|
|
842
|
+
actions: "clearError"
|
|
843
|
+
},
|
|
844
|
+
CLOSED: {
|
|
845
|
+
target: ".disconnected",
|
|
846
|
+
actions: "clearError"
|
|
847
|
+
},
|
|
848
|
+
DISCONNECT: ".disconnected",
|
|
849
|
+
END: {
|
|
850
|
+
target: ".disconnected",
|
|
851
|
+
actions: "clearError"
|
|
852
|
+
},
|
|
853
|
+
TURN_ERROR: { actions: "setError" },
|
|
854
|
+
FATAL: {
|
|
855
|
+
target: ".error",
|
|
856
|
+
actions: "setError"
|
|
857
|
+
},
|
|
858
|
+
FAILED: {
|
|
859
|
+
target: ".error",
|
|
860
|
+
actions: "setError"
|
|
861
|
+
},
|
|
862
|
+
LISTEN: {
|
|
863
|
+
guard: NOT_FATAL,
|
|
864
|
+
target: ".listening"
|
|
865
|
+
},
|
|
866
|
+
RESET: {
|
|
867
|
+
guard: NOT_FATAL,
|
|
868
|
+
target: ".listening",
|
|
869
|
+
actions: "clearError"
|
|
870
|
+
},
|
|
871
|
+
THINK: {
|
|
872
|
+
guard: NOT_FATAL,
|
|
873
|
+
target: ".thinking"
|
|
874
|
+
},
|
|
875
|
+
ACTIVITY: {
|
|
876
|
+
guard: and([NOT_FATAL, "hasError"]),
|
|
877
|
+
actions: "clearError"
|
|
878
|
+
}
|
|
879
|
+
},
|
|
880
|
+
states: {
|
|
881
|
+
disconnected: { on: { SPEAK: {
|
|
882
|
+
guard: not("hasError"),
|
|
883
|
+
target: "speaking"
|
|
884
|
+
} } },
|
|
885
|
+
connecting: { on: { SOCKET_OPEN: "ready" } },
|
|
886
|
+
ready: { on: { SPEAK: "speaking" } },
|
|
887
|
+
listening: { on: { SPEAK: "speaking" } },
|
|
888
|
+
thinking: { on: { SPEAK: "speaking" } },
|
|
889
|
+
speaking: {},
|
|
890
|
+
/**
|
|
891
|
+
* A failure is on screen.
|
|
892
|
+
*
|
|
893
|
+
* `SPEAK` is absent deliberately — that is half the guard
|
|
894
|
+
* `playAudioChunk` used to spell, and the other half is `disconnected`'s
|
|
895
|
+
* above. `ACTIVITY` recovers to `listening` rather than merely clearing
|
|
896
|
+
* the banner: the socket is demonstrably open (we are handling a server
|
|
897
|
+
* event), so `disconnected` would misreport a live session.
|
|
898
|
+
*/
|
|
899
|
+
error: { on: {
|
|
900
|
+
ACTIVITY: {
|
|
901
|
+
guard: NOT_FATAL,
|
|
902
|
+
target: "listening",
|
|
903
|
+
actions: "clearError"
|
|
904
|
+
},
|
|
905
|
+
CLOSED: {}
|
|
906
|
+
} }
|
|
907
|
+
}
|
|
908
|
+
},
|
|
909
|
+
/**
|
|
910
|
+
* Whether the session is fatally over.
|
|
911
|
+
*
|
|
912
|
+
* Separate from `phase` because it OUTLIVES the `error` state: only a
|
|
913
|
+
* completed handshake clears it, and the phase moves through `connecting`
|
|
914
|
+
* and `ready` on the way to one. A substate of `error` could not say that.
|
|
915
|
+
*/
|
|
916
|
+
fatal: {
|
|
917
|
+
initial: "no",
|
|
918
|
+
states: {
|
|
919
|
+
no: { on: { FATAL: "yes" } },
|
|
920
|
+
yes: { on: { HANDSHAKE_COMPLETE: "no" } }
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
/** Create the state machine for one browser session. */
|
|
926
|
+
function createSessionStateMachine() {
|
|
927
|
+
const actor = createActor(sessionStateMachine).start();
|
|
928
|
+
function snapshot() {
|
|
929
|
+
const at = actor.getSnapshot();
|
|
930
|
+
return {
|
|
931
|
+
state: at.value.phase,
|
|
932
|
+
error: at.context.error
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
return {
|
|
936
|
+
snapshot,
|
|
937
|
+
apply(event) {
|
|
938
|
+
actor.send(event);
|
|
939
|
+
return snapshot();
|
|
940
|
+
},
|
|
941
|
+
fatal: () => actor.getSnapshot().matches({ fatal: "yes" })
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
//#endregion
|
|
846
945
|
//#region session-core.ts
|
|
847
946
|
/**
|
|
848
947
|
* Framework-agnostic voice session core.
|
|
@@ -904,7 +1003,12 @@ function createSessionCore(options) {
|
|
|
904
1003
|
"userTranscript",
|
|
905
1004
|
"agentTranscript"
|
|
906
1005
|
];
|
|
1006
|
+
/** Does `partial` leave this field exactly as it already is? */
|
|
1007
|
+
function isUnchanged(key, partial) {
|
|
1008
|
+
return partial[key] === currentSnapshot[key];
|
|
1009
|
+
}
|
|
907
1010
|
function updateState(partial) {
|
|
1011
|
+
if (Object.keys(partial).every((key) => isUnchanged(key, partial))) return;
|
|
908
1012
|
currentSnapshot = contentKeys.some((key) => key in partial && partial[key] !== currentSnapshot[key]) ? {
|
|
909
1013
|
...currentSnapshot,
|
|
910
1014
|
...partial,
|
|
@@ -924,10 +1028,15 @@ function createSessionCore(options) {
|
|
|
924
1028
|
subscribers.delete(callback);
|
|
925
1029
|
};
|
|
926
1030
|
}
|
|
1031
|
+
/**
|
|
1032
|
+
* The session's `state` and `error`, as one fact rather than two fields
|
|
1033
|
+
* thirteen call sites wrote independently — see `session-core-state.ts`,
|
|
1034
|
+
* which carries the three shipped bugs that arrangement produced.
|
|
1035
|
+
*/
|
|
1036
|
+
const agentState = createSessionStateMachine();
|
|
927
1037
|
const conn = {
|
|
928
1038
|
ws: null,
|
|
929
1039
|
retiredByServer: false,
|
|
930
|
-
fatalError: false,
|
|
931
1040
|
voiceIO: null,
|
|
932
1041
|
audioSetupInFlight: false,
|
|
933
1042
|
generation: createEpoch(),
|
|
@@ -971,12 +1080,14 @@ function createSessionCore(options) {
|
|
|
971
1080
|
getSnapshot,
|
|
972
1081
|
updateState,
|
|
973
1082
|
conn,
|
|
1083
|
+
agentState,
|
|
974
1084
|
cleanupAudio
|
|
975
1085
|
});
|
|
976
1086
|
const audioDeps = {
|
|
977
1087
|
sendJson,
|
|
978
1088
|
sendAudio,
|
|
979
1089
|
updateState,
|
|
1090
|
+
agentState,
|
|
980
1091
|
settleWhenAudioDrained,
|
|
981
1092
|
cleanupAudio
|
|
982
1093
|
};
|
|
@@ -1009,10 +1120,7 @@ function createSessionCore(options) {
|
|
|
1009
1120
|
disconnect();
|
|
1010
1121
|
return;
|
|
1011
1122
|
}
|
|
1012
|
-
updateState({
|
|
1013
|
-
state: "connecting",
|
|
1014
|
-
error: null
|
|
1015
|
-
});
|
|
1123
|
+
updateState(agentState.apply({ type: "CONNECT" }));
|
|
1016
1124
|
loadAudioModules().catch(() => {});
|
|
1017
1125
|
teardownConnection();
|
|
1018
1126
|
conn.generation.bump();
|
|
@@ -1032,7 +1140,7 @@ function createSessionCore(options) {
|
|
|
1032
1140
|
cleanupAudio();
|
|
1033
1141
|
conn.generation.bump();
|
|
1034
1142
|
updateState({
|
|
1035
|
-
|
|
1143
|
+
...agentState.apply({ type: "CONNECT" }),
|
|
1036
1144
|
recording: false
|
|
1037
1145
|
});
|
|
1038
1146
|
},
|
|
@@ -1042,15 +1150,17 @@ function createSessionCore(options) {
|
|
|
1042
1150
|
socket.close();
|
|
1043
1151
|
conn.ws = null;
|
|
1044
1152
|
updateState({
|
|
1045
|
-
|
|
1046
|
-
|
|
1153
|
+
...agentState.apply({
|
|
1154
|
+
type: "FAILED",
|
|
1155
|
+
error: HANDSHAKE_ERROR
|
|
1156
|
+
}),
|
|
1047
1157
|
running: false,
|
|
1048
1158
|
recording: false
|
|
1049
1159
|
});
|
|
1050
1160
|
}
|
|
1051
1161
|
});
|
|
1052
1162
|
socket.addEventListener("open", () => {
|
|
1053
|
-
updateState({
|
|
1163
|
+
updateState(agentState.apply({ type: "SOCKET_OPEN" }));
|
|
1054
1164
|
handshake.arm();
|
|
1055
1165
|
}, { signal: sig });
|
|
1056
1166
|
socket.addEventListener("message", (event) => {
|
|
@@ -1070,7 +1180,7 @@ function createSessionCore(options) {
|
|
|
1070
1180
|
conn.generation.bump();
|
|
1071
1181
|
socketErrored = false;
|
|
1072
1182
|
updateState({
|
|
1073
|
-
|
|
1183
|
+
...agentState.apply({ type: "CONNECT" }),
|
|
1074
1184
|
recording: false
|
|
1075
1185
|
});
|
|
1076
1186
|
return;
|
|
@@ -1078,22 +1188,14 @@ function createSessionCore(options) {
|
|
|
1078
1188
|
controller.abort();
|
|
1079
1189
|
socket.close();
|
|
1080
1190
|
conn.ws = null;
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
});
|
|
1090
|
-
else if (currentSnapshot.state === "error") updateState({
|
|
1091
|
-
running: false,
|
|
1092
|
-
recording: false
|
|
1093
|
-
});
|
|
1094
|
-
else updateState({
|
|
1095
|
-
state: "disconnected",
|
|
1096
|
-
error: null,
|
|
1191
|
+
updateState({
|
|
1192
|
+
...socketErrored ? agentState.apply({
|
|
1193
|
+
type: "FAILED",
|
|
1194
|
+
error: {
|
|
1195
|
+
code: "connection",
|
|
1196
|
+
message: "WebSocket connection error"
|
|
1197
|
+
}
|
|
1198
|
+
}) : agentState.apply({ type: "CLOSED" }),
|
|
1097
1199
|
running: false,
|
|
1098
1200
|
recording: false
|
|
1099
1201
|
});
|
|
@@ -1103,7 +1205,7 @@ function createSessionCore(options) {
|
|
|
1103
1205
|
if (!openSocket()) return;
|
|
1104
1206
|
conn.turn.bump();
|
|
1105
1207
|
conn.voiceIO?.flush();
|
|
1106
|
-
updateState({
|
|
1208
|
+
updateState(agentState.apply({ type: "LISTEN" }));
|
|
1107
1209
|
sendJson({ type: "cancel" });
|
|
1108
1210
|
}
|
|
1109
1211
|
function reset() {
|
|
@@ -1119,7 +1221,7 @@ function createSessionCore(options) {
|
|
|
1119
1221
|
function disconnect() {
|
|
1120
1222
|
teardownConnection();
|
|
1121
1223
|
updateState({
|
|
1122
|
-
|
|
1224
|
+
...agentState.apply({ type: "DISCONNECT" }),
|
|
1123
1225
|
running: false,
|
|
1124
1226
|
recording: false
|
|
1125
1227
|
});
|
|
@@ -1143,7 +1245,7 @@ function createSessionCore(options) {
|
|
|
1143
1245
|
dialer.forget();
|
|
1144
1246
|
updateState({
|
|
1145
1247
|
...CLEARED_SESSION_STATE,
|
|
1146
|
-
|
|
1248
|
+
...agentState.apply({ type: "END" }),
|
|
1147
1249
|
started: false,
|
|
1148
1250
|
running: false,
|
|
1149
1251
|
recording: false
|
|
@@ -1166,4 +1268,4 @@ function createSessionCore(options) {
|
|
|
1166
1268
|
};
|
|
1167
1269
|
}
|
|
1168
1270
|
//#endregion
|
|
1169
|
-
export {
|
|
1271
|
+
export { createSessionCore as t };
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { ClientMessage } from "@alexkroman1/aai/protocol";
|
|
2
2
|
import type { VoiceIO } from "./audio.ts";
|
|
3
|
+
import type { SessionStateMachine } from "./session-core-state.ts";
|
|
3
4
|
import type { ConnState, SessionSnapshot } from "./session-core-types.ts";
|
|
4
5
|
/** Dependencies `initAudioCapture` needs from the owning session core. */
|
|
5
6
|
export type AudioSetupDeps = {
|
|
6
7
|
sendJson: (msg: ClientMessage) => void;
|
|
7
8
|
sendAudio: (bytes: ArrayBuffer) => void;
|
|
8
9
|
updateState: (partial: Partial<SessionSnapshot>) => void;
|
|
10
|
+
/** The session's state and error, as one fact — see `session-core-state.ts`. */
|
|
11
|
+
agentState: SessionStateMachine;
|
|
9
12
|
/** Turn-boundary-guarded drain from the message handlers — replays a
|
|
10
13
|
* buffered `audio_done` without stomping a barge-in's state. */
|
|
11
14
|
settleWhenAudioDrained: (io: VoiceIO) => void;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SessionStateMachine } from "./session-core-state.ts";
|
|
1
2
|
import type { ConnState, SessionSnapshot } from "./session-core-types.ts";
|
|
2
3
|
/**
|
|
3
4
|
* Snapshot fields cleared when a session's conversation state is wiped —
|
|
@@ -25,6 +26,8 @@ type MessageHandlerDeps = {
|
|
|
25
26
|
getSnapshot: () => SessionSnapshot;
|
|
26
27
|
updateState: (partial: Partial<SessionSnapshot>) => void;
|
|
27
28
|
conn: ConnState;
|
|
29
|
+
/** The session's state and error, as one fact — see `session-core-state.ts`. */
|
|
30
|
+
agentState: SessionStateMachine;
|
|
28
31
|
/** Release the microphone/VoiceIO (the session core's `cleanupAudio`). */
|
|
29
32
|
cleanupAudio: () => void;
|
|
30
33
|
};
|