@alexkroman1/aai-ui 17.0.0 → 18.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/dist/context.d.ts +27 -5
- package/dist/context.js +3 -3
- package/dist/default-client/assets/{index-18HWH46b.css → index-5XG8PqGw.css} +1 -1
- package/dist/default-client/assets/index-tc-nAWq3.js +121 -0
- package/dist/default-client/index.html +2 -2
- package/dist/define-client.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +165 -5
- package/dist/{session-core-DHE1bQ1J.js → session-core-TOdm_Con.js} +36 -2
- package/dist/session-core-types.d.ts +47 -0
- package/dist/session-core-user-turn.d.ts +30 -0
- package/dist/session-core.js +1 -1
- package/dist/use-push-to-talk.d.ts +105 -0
- package/package.json +2 -2
- package/dist/default-client/assets/index-gJbwWrWS.js +0 -121
|
@@ -6,9 +6,9 @@
|
|
|
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-tc-nAWq3.js"></script>
|
|
10
10
|
<link rel="modulepreload" crossorigin href="./assets/client-audio-constants-CP13UQZt.js">
|
|
11
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
11
|
+
<link rel="stylesheet" crossorigin href="./assets/index-5XG8PqGw.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<main id="app"></main>
|
package/dist/define-client.js
CHANGED
|
@@ -5,7 +5,7 @@ import { SessionProvider, ThemeProvider } from "./context.js";
|
|
|
5
5
|
import { t as ToolConfigContext } from "./tool-config-context-Es4YUzV2.js";
|
|
6
6
|
import { SidebarLayout } from "./components/sidebar-layout.js";
|
|
7
7
|
import { StartScreen } from "./components/start-screen.js";
|
|
8
|
-
import { t as createBrowserSession } from "./session-core-
|
|
8
|
+
import { t as createBrowserSession } from "./session-core-TOdm_Con.js";
|
|
9
9
|
import { jsx } from "react/jsx-runtime";
|
|
10
10
|
import { createElement, useEffect, useState } from "react";
|
|
11
11
|
import { flushSync } from "react-dom";
|
package/dist/index.d.ts
CHANGED
|
@@ -90,12 +90,13 @@ export { mountClient } from "./define-client.tsx";
|
|
|
90
90
|
export { useAgentState, useEvent, useToolCallStart, useToolResult } from "./hooks.ts";
|
|
91
91
|
export { mountPage, type PageConfig, type PageHandle } from "./page.tsx";
|
|
92
92
|
export { createBrowserSession } from "./session-core.ts";
|
|
93
|
-
export type { AgentCustomEvent, BrowserSession, SessionSnapshot, } from "./session-core-types.ts";
|
|
93
|
+
export type { AgentCustomEvent, BrowserSession, browserSessionBrand, SessionSnapshot, UserTurnControls, } from "./session-core-types.ts";
|
|
94
94
|
export type { AgentState, ChatMessage, ClientTheme, SessionError, SessionErrorCode, ToolCallInfo, VoiceSessionOptions, WebSocketConstructor, } from "./types.ts";
|
|
95
95
|
export { type ConversationItem, type UseConversationResult, useConversation, } from "./use-conversation.ts";
|
|
96
96
|
export { type UseCopyResult, useCopy } from "./use-copy.ts";
|
|
97
97
|
export { type UseDownloadUrlOptions, type UseDownloadUrlResult, useDownloadUrl, } from "./use-download-url.ts";
|
|
98
98
|
export { type UseFlashResult, useFlash } from "./use-flash.ts";
|
|
99
|
+
export { type UsePushToTalkOptions, type UsePushToTalkResult, usePushToTalk, } from "./use-push-to-talk.ts";
|
|
99
100
|
export { useRunKey } from "./use-run-key.ts";
|
|
100
101
|
export { type UseSessionControlsResult, useSessionControls } from "./use-session-controls.ts";
|
|
101
102
|
export { type UseUserTranscriptResult, useUserTranscript } from "./use-user-transcript.ts";
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { a as AGENT_STATE_LABELS, i as SessionErrorBanner, n as ConsoleShell, r
|
|
|
2
2
|
import { n as setPageTitle } from "./_utils-DnQDM9Uy.js";
|
|
3
3
|
import { n as fetchClientConfig } from "./client-config-CFhkMkmv.js";
|
|
4
4
|
import { a as AutoScroll, i as useConversation, n as Markdown, r as ConversationView, t as MessageList } from "./message-list-CJX560b5.js";
|
|
5
|
-
import { ThemeProvider, useSession, useSessionActions, useSessionError, useSessionSelector, useSessionStatus, useTheme } from "./context.js";
|
|
5
|
+
import { ThemeProvider, useSession, useSessionActions, useSessionCore, useSessionError, useSessionSelector, useSessionStatus, useTheme } from "./context.js";
|
|
6
6
|
import { a as inkTint, i as focusRingStyle, n as FOCUS_RING } from "./_colors-CpZO-88A.js";
|
|
7
7
|
import { Button } from "./components/button.js";
|
|
8
8
|
import { a as useFlash, i as useCopy } from "./url-chips-0n35fPJY.js";
|
|
@@ -12,7 +12,7 @@ import { n as ToolCallRow } from "./tool-call-block-4gXoL_eP.js";
|
|
|
12
12
|
import { SidebarLayout } from "./components/sidebar-layout.js";
|
|
13
13
|
import { StartScreen } from "./components/start-screen.js";
|
|
14
14
|
import { a as useWorkflowProgress, c as isTerminal, o as useWorkflowApiRef, r as useWorkflowRun, s as createWorkflowApi } from "./use-workflow-run-ZqJWJn2-.js";
|
|
15
|
-
import { i as urlSlot, n as storageGet, r as storageSet, t as createBrowserSession } from "./session-core-
|
|
15
|
+
import { i as urlSlot, n as storageGet, r as storageSet, t as createBrowserSession } from "./session-core-TOdm_Con.js";
|
|
16
16
|
import { mountClient, mountRoot, resolveContainer } from "./define-client.js";
|
|
17
17
|
import { useAgentState, useEvent, useToolCallStart, useToolResult } from "./hooks.js";
|
|
18
18
|
import { errorMessage } from "@alexkroman1/aai";
|
|
@@ -893,7 +893,7 @@ function SubmitButton({ children, pending = false, pendingLabel = "Working…",
|
|
|
893
893
|
* unusual for even that component's render slot takes the hook alone.
|
|
894
894
|
*/
|
|
895
895
|
const selectStarted = (snapshot) => snapshot.started;
|
|
896
|
-
const selectRunning = (snapshot) => snapshot.running;
|
|
896
|
+
const selectRunning$1 = (snapshot) => snapshot.running;
|
|
897
897
|
/**
|
|
898
898
|
* The state and the actions a Start / Pause–Resume / New conversation / End row
|
|
899
899
|
* renders from, on two one-field subscriptions.
|
|
@@ -923,7 +923,7 @@ const selectRunning = (snapshot) => snapshot.running;
|
|
|
923
923
|
function useSessionControls() {
|
|
924
924
|
const { start, toggle, restart, end } = useSessionActions();
|
|
925
925
|
const started = useSessionSelector(selectStarted);
|
|
926
|
-
const running = useSessionSelector(selectRunning);
|
|
926
|
+
const running = useSessionSelector(selectRunning$1);
|
|
927
927
|
return useMemo(() => ({
|
|
928
928
|
started,
|
|
929
929
|
running,
|
|
@@ -3011,6 +3011,166 @@ function useDownloadUrl(uploadId, options = {}) {
|
|
|
3011
3011
|
return state;
|
|
3012
3012
|
}
|
|
3013
3013
|
//#endregion
|
|
3014
|
+
//#region src/use-push-to-talk.ts
|
|
3015
|
+
/**
|
|
3016
|
+
* `usePushToTalk` — a hold-to-speak button, for an agent that declares
|
|
3017
|
+
* `turnDetection: "manual"`.
|
|
3018
|
+
*
|
|
3019
|
+
* The three session edges underneath (`session.userTurn`'s `start`, `commit`
|
|
3020
|
+
* and `clear`) are one line each; what a button needs on top of them is
|
|
3021
|
+
* where hand-written versions go wrong, and every one of the four is a turn
|
|
3022
|
+
* that is left OPEN — the microphone live and nothing ever answered:
|
|
3023
|
+
*
|
|
3024
|
+
* - **The pointer leaves.** A `mouseup` outside the button never reaches it.
|
|
3025
|
+
* The handlers here capture the pointer on the way down, so the release
|
|
3026
|
+
* arrives wherever it happens; an `onPointerCancel` (a scroll gesture, a
|
|
3027
|
+
* system dialog) DISCARDS the turn rather than answering half a sentence.
|
|
3028
|
+
* - **The key repeats.** Holding a key fires `keydown` over and over, and each
|
|
3029
|
+
* one would re-open the turn. Only the first counts.
|
|
3030
|
+
* - **The window loses focus mid-hold.** The `keyup` goes to another window
|
|
3031
|
+
* and is never delivered, so the turn is committed on `blur` — what the
|
|
3032
|
+
* caller would have done had the release been seen.
|
|
3033
|
+
* - **The component unmounts mid-hold.** The turn is discarded on the way out.
|
|
3034
|
+
*
|
|
3035
|
+
* And one that is a nuisance rather than a stuck turn: the hold key is ignored
|
|
3036
|
+
* while focus is in a text field, where Space is a character.
|
|
3037
|
+
*/
|
|
3038
|
+
const selectRunning = (snapshot) => snapshot.running;
|
|
3039
|
+
/** True when a key event came from somewhere typing belongs. */
|
|
3040
|
+
function isTypingTarget(target) {
|
|
3041
|
+
if (typeof HTMLElement === "undefined" || !(target instanceof HTMLElement)) return false;
|
|
3042
|
+
return target.isContentEditable || [
|
|
3043
|
+
"INPUT",
|
|
3044
|
+
"TEXTAREA",
|
|
3045
|
+
"SELECT"
|
|
3046
|
+
].includes(target.tagName);
|
|
3047
|
+
}
|
|
3048
|
+
/**
|
|
3049
|
+
* Hold-to-speak over the session's push-to-talk methods, with the four ways a
|
|
3050
|
+
* turn gets stuck open handled — see this module's doc.
|
|
3051
|
+
*
|
|
3052
|
+
* Must be used inside the provider `mountClient()` installs, against an agent
|
|
3053
|
+
* declaring `turnDetection: "manual"`; any other agent ignores the commands and
|
|
3054
|
+
* its server says so once.
|
|
3055
|
+
*
|
|
3056
|
+
* @example A hold-to-talk button
|
|
3057
|
+
* ```tsx
|
|
3058
|
+
* import { usePushToTalk } from "@alexkroman1/aai-ui";
|
|
3059
|
+
*
|
|
3060
|
+
* function TalkButton() {
|
|
3061
|
+
* const { talking, buttonProps } = usePushToTalk();
|
|
3062
|
+
* return (
|
|
3063
|
+
* <button type="button" {...buttonProps}>
|
|
3064
|
+
* {talking ? "Listening… release to send" : "Hold to talk (or hold Space)"}
|
|
3065
|
+
* </button>
|
|
3066
|
+
* );
|
|
3067
|
+
* }
|
|
3068
|
+
* ```
|
|
3069
|
+
*
|
|
3070
|
+
* @public
|
|
3071
|
+
*/
|
|
3072
|
+
function usePushToTalk(options = {}) {
|
|
3073
|
+
const holdKey = options.holdKey ?? "Space";
|
|
3074
|
+
const { userTurn } = useSessionCore();
|
|
3075
|
+
const ready = useSessionSelector(selectRunning);
|
|
3076
|
+
const [talking, setTalking] = useState(false);
|
|
3077
|
+
const held = useRef(false);
|
|
3078
|
+
const press = useCallback(() => {
|
|
3079
|
+
if (held.current || !ready) return;
|
|
3080
|
+
held.current = true;
|
|
3081
|
+
setTalking(true);
|
|
3082
|
+
userTurn.start();
|
|
3083
|
+
}, [ready, userTurn]);
|
|
3084
|
+
const release = useCallback(() => {
|
|
3085
|
+
if (!held.current) return;
|
|
3086
|
+
held.current = false;
|
|
3087
|
+
setTalking(false);
|
|
3088
|
+
userTurn.commit();
|
|
3089
|
+
}, [userTurn]);
|
|
3090
|
+
const cancel = useCallback(() => {
|
|
3091
|
+
if (!held.current) return;
|
|
3092
|
+
held.current = false;
|
|
3093
|
+
setTalking(false);
|
|
3094
|
+
userTurn.clear();
|
|
3095
|
+
}, [userTurn]);
|
|
3096
|
+
useEffect(() => {
|
|
3097
|
+
if (!ready && held.current) {
|
|
3098
|
+
held.current = false;
|
|
3099
|
+
setTalking(false);
|
|
3100
|
+
}
|
|
3101
|
+
}, [ready]);
|
|
3102
|
+
useEffect(() => {
|
|
3103
|
+
if (holdKey === false || typeof window === "undefined") return;
|
|
3104
|
+
const down = (event) => {
|
|
3105
|
+
if (event.code !== holdKey || event.repeat || isTypingTarget(event.target)) return;
|
|
3106
|
+
event.preventDefault();
|
|
3107
|
+
press();
|
|
3108
|
+
};
|
|
3109
|
+
const up = (event) => {
|
|
3110
|
+
if (event.code !== holdKey) return;
|
|
3111
|
+
event.preventDefault();
|
|
3112
|
+
release();
|
|
3113
|
+
};
|
|
3114
|
+
window.addEventListener("keydown", down);
|
|
3115
|
+
window.addEventListener("keyup", up);
|
|
3116
|
+
window.addEventListener("blur", release);
|
|
3117
|
+
return () => {
|
|
3118
|
+
window.removeEventListener("keydown", down);
|
|
3119
|
+
window.removeEventListener("keyup", up);
|
|
3120
|
+
window.removeEventListener("blur", release);
|
|
3121
|
+
};
|
|
3122
|
+
}, [
|
|
3123
|
+
holdKey,
|
|
3124
|
+
press,
|
|
3125
|
+
release
|
|
3126
|
+
]);
|
|
3127
|
+
const cancelRef = useRef(cancel);
|
|
3128
|
+
cancelRef.current = cancel;
|
|
3129
|
+
useEffect(() => () => cancelRef.current(), []);
|
|
3130
|
+
const buttonProps = useMemo(() => ({
|
|
3131
|
+
onPointerDown: (event) => {
|
|
3132
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
3133
|
+
press();
|
|
3134
|
+
},
|
|
3135
|
+
onPointerUp: release,
|
|
3136
|
+
onPointerCancel: cancel,
|
|
3137
|
+
onKeyDown: (event) => {
|
|
3138
|
+
if (event.key !== " " && event.key !== "Enter" || event.repeat) return;
|
|
3139
|
+
event.preventDefault();
|
|
3140
|
+
press();
|
|
3141
|
+
},
|
|
3142
|
+
onKeyUp: (event) => {
|
|
3143
|
+
if (event.key !== " " && event.key !== "Enter") return;
|
|
3144
|
+
event.preventDefault();
|
|
3145
|
+
release();
|
|
3146
|
+
},
|
|
3147
|
+
onContextMenu: (event) => event.preventDefault(),
|
|
3148
|
+
disabled: !ready,
|
|
3149
|
+
"aria-pressed": talking
|
|
3150
|
+
}), [
|
|
3151
|
+
press,
|
|
3152
|
+
release,
|
|
3153
|
+
cancel,
|
|
3154
|
+
ready,
|
|
3155
|
+
talking
|
|
3156
|
+
]);
|
|
3157
|
+
return useMemo(() => ({
|
|
3158
|
+
talking,
|
|
3159
|
+
ready,
|
|
3160
|
+
press,
|
|
3161
|
+
release,
|
|
3162
|
+
cancel,
|
|
3163
|
+
buttonProps
|
|
3164
|
+
}), [
|
|
3165
|
+
talking,
|
|
3166
|
+
ready,
|
|
3167
|
+
press,
|
|
3168
|
+
release,
|
|
3169
|
+
cancel,
|
|
3170
|
+
buttonProps
|
|
3171
|
+
]);
|
|
3172
|
+
}
|
|
3173
|
+
//#endregion
|
|
3014
3174
|
//#region src/use-workflow-runs.ts
|
|
3015
3175
|
/**
|
|
3016
3176
|
* The RUNS a workflow has had — the list a page shows beside its form.
|
|
@@ -3377,4 +3537,4 @@ function fileAt(input, field) {
|
|
|
3377
3537
|
return filesOf(input[field])[0];
|
|
3378
3538
|
}
|
|
3379
3539
|
//#endregion
|
|
3380
|
-
export { AGENT_STATE_LABELS, AudioResult, AutoScroll, BulletList, Button, ChatView, CheckboxField, ConsoleShell, Controls, ConversationView, Facts, Field, FileField, Form, Markdown, MessageList, NumberField, SelectField, SessionControls, SessionErrorBanner, SessionStateDot, SidebarLayout, StartScreen, SubmitButton, TextAreaField, TextField, ToolCallRow, UploadProgressBar, WORKFLOW_STATUS_LABELS, WorkflowFields, WorkflowPendingNote, WorkflowProgress, WorkflowRunError, WorkflowRunPanel, createBrowserSession, createWorkflowApi, fetchClientConfig, fieldKindFor, isTerminal, mountClient, mountPage, useAgentState, useConversation, useCopy, useDownloadUrl, useEvent, useFlash, useRunKey, useSession, useSessionActions, useSessionControls, useSessionError, useSessionSelector, useSessionStatus, useTheme, useToolCallStart, useToolResult, useUserTranscript, useWorkflowProgress, useWorkflowRun, useWorkflowRuns, useWorkflowStream, useWorkflowSubmit, useWorkflows };
|
|
3540
|
+
export { AGENT_STATE_LABELS, AudioResult, AutoScroll, BulletList, Button, ChatView, CheckboxField, ConsoleShell, Controls, ConversationView, Facts, Field, FileField, Form, Markdown, MessageList, NumberField, SelectField, SessionControls, SessionErrorBanner, SessionStateDot, SidebarLayout, StartScreen, SubmitButton, TextAreaField, TextField, ToolCallRow, UploadProgressBar, WORKFLOW_STATUS_LABELS, WorkflowFields, WorkflowPendingNote, WorkflowProgress, WorkflowRunError, WorkflowRunPanel, createBrowserSession, createWorkflowApi, fetchClientConfig, fieldKindFor, isTerminal, mountClient, mountPage, useAgentState, useConversation, useCopy, useDownloadUrl, useEvent, useFlash, usePushToTalk, useRunKey, useSession, useSessionActions, useSessionControls, useSessionError, useSessionSelector, useSessionStatus, useTheme, useToolCallStart, useToolResult, useUserTranscript, useWorkflowProgress, useWorkflowRun, useWorkflowRuns, useWorkflowStream, useWorkflowSubmit, useWorkflows };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { r as loadClientConfig, t as buildAgentUrl } from "./client-config-CFhkMkmv.js";
|
|
2
2
|
import { MIC_SEND_MAX_BUFFERED_BYTES } from "./types.js";
|
|
3
|
-
import {
|
|
4
|
-
import { errorMessage, safeJsonParse } from "@alexkroman1/aai";
|
|
3
|
+
import { lenientParse } from "@alexkroman1/aai/protocol";
|
|
4
|
+
import { SessionEventSchema, errorMessage, safeJsonParse } from "@alexkroman1/aai";
|
|
5
5
|
import { omitUndefined } from "@alexkroman1/aai/utils";
|
|
6
6
|
import { DEFAULT_MAX_HISTORY, WS_OPEN, createEpoch, toArgsRecord } from "@alexkroman1/aai/internal";
|
|
7
7
|
import { and, assign, createActor, fromPromise, not, setup, stateIn } from "xstate";
|
|
@@ -1279,6 +1279,31 @@ function createSessionStateMachine() {
|
|
|
1279
1279
|
};
|
|
1280
1280
|
}
|
|
1281
1281
|
//#endregion
|
|
1282
|
+
//#region src/session-core-user-turn.ts
|
|
1283
|
+
/** Build the three push-to-talk edges. @internal */
|
|
1284
|
+
function createUserTurnActions(deps) {
|
|
1285
|
+
return {
|
|
1286
|
+
start() {
|
|
1287
|
+
if (!deps.connected()) return;
|
|
1288
|
+
const { state } = deps.snapshot();
|
|
1289
|
+
if (state === "speaking" || state === "thinking") {
|
|
1290
|
+
deps.bargeIn();
|
|
1291
|
+
deps.updateState(deps.agentState.apply({ type: "LISTEN" }));
|
|
1292
|
+
}
|
|
1293
|
+
deps.sendJson({ type: "user_turn_start" });
|
|
1294
|
+
},
|
|
1295
|
+
commit() {
|
|
1296
|
+
if (!deps.connected()) return;
|
|
1297
|
+
deps.sendJson({ type: "user_turn_commit" });
|
|
1298
|
+
},
|
|
1299
|
+
clear() {
|
|
1300
|
+
if (!deps.connected()) return;
|
|
1301
|
+
deps.updateState({ userTranscript: null });
|
|
1302
|
+
deps.sendJson({ type: "user_turn_clear" });
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
//#endregion
|
|
1282
1307
|
//#region src/session-core.ts
|
|
1283
1308
|
/**
|
|
1284
1309
|
* Framework-agnostic voice session core.
|
|
@@ -1534,6 +1559,14 @@ function createBrowserSession(options) {
|
|
|
1534
1559
|
updateState(agentState.apply({ type: "LISTEN" }));
|
|
1535
1560
|
sendJson({ type: "cancel" });
|
|
1536
1561
|
}
|
|
1562
|
+
const userTurn = createUserTurnActions({
|
|
1563
|
+
snapshot: () => currentSnapshot,
|
|
1564
|
+
connected: () => openSocket() !== null,
|
|
1565
|
+
bargeIn: () => bargeIn(conn, audio),
|
|
1566
|
+
agentState,
|
|
1567
|
+
updateState,
|
|
1568
|
+
sendJson
|
|
1569
|
+
});
|
|
1537
1570
|
function reset() {
|
|
1538
1571
|
bargeIn(conn, audio);
|
|
1539
1572
|
if (openSocket()) {
|
|
@@ -1583,6 +1616,7 @@ function createBrowserSession(options) {
|
|
|
1583
1616
|
subscribe,
|
|
1584
1617
|
connect,
|
|
1585
1618
|
cancel,
|
|
1619
|
+
userTurn: Object.freeze(userTurn),
|
|
1586
1620
|
resetState,
|
|
1587
1621
|
reset,
|
|
1588
1622
|
disconnect,
|
|
@@ -140,6 +140,45 @@ export type SessionSnapshot = {
|
|
|
140
140
|
*/
|
|
141
141
|
readonly running: boolean;
|
|
142
142
|
};
|
|
143
|
+
/**
|
|
144
|
+
* The seal on a {@link BrowserSession}.
|
|
145
|
+
*
|
|
146
|
+
* TYPE-ONLY — there is no value at run time, so an object literal cannot carry
|
|
147
|
+
* the key and only `createBrowserSession` mints a session. That is what lets
|
|
148
|
+
* the handle grow a member without breaking anybody: push-to-talk added three
|
|
149
|
+
* REQUIRED methods and broke every hand-written double, which is the failure a
|
|
150
|
+
* handle a caller RECEIVES should not be able to cause.
|
|
151
|
+
*
|
|
152
|
+
* @public
|
|
153
|
+
*/
|
|
154
|
+
export declare const browserSessionBrand: unique symbol;
|
|
155
|
+
/**
|
|
156
|
+
* Push-to-talk's three edges on a {@link BrowserSession} — `session.userTurn`.
|
|
157
|
+
*
|
|
158
|
+
* Only an agent declaring `turnDetection: "manual"` honours them; any other
|
|
159
|
+
* agent logs once and ignores them, because its transcriber already ends each
|
|
160
|
+
* turn on a pause. `usePushToTalk` is the hook a button is built on, and the
|
|
161
|
+
* way a `client.tsx` reaches these: a sub-handle rather than three methods on
|
|
162
|
+
* the session, so a feature one agent in many declares is not three members
|
|
163
|
+
* every session carries in its autocomplete.
|
|
164
|
+
*
|
|
165
|
+
* @public
|
|
166
|
+
*/
|
|
167
|
+
export type UserTurnControls = {
|
|
168
|
+
/**
|
|
169
|
+
* OPEN a turn — the button went down. Stops the agent if it is speaking
|
|
170
|
+
* (discarding its queued audio here at once, rather than a round trip later)
|
|
171
|
+
* and lets the microphone through to the transcriber.
|
|
172
|
+
*/
|
|
173
|
+
start(): void;
|
|
174
|
+
/** CLOSE the turn and have the agent answer everything said since `start()` — the button came up. */
|
|
175
|
+
commit(): void;
|
|
176
|
+
/**
|
|
177
|
+
* Close the turn and THROW AWAY what was said in it — a cancelled press (the
|
|
178
|
+
* pointer left the button, Escape). The agent answers nothing.
|
|
179
|
+
*/
|
|
180
|
+
clear(): void;
|
|
181
|
+
};
|
|
143
182
|
/**
|
|
144
183
|
* A framework-agnostic voice session that manages WebSocket communication,
|
|
145
184
|
* audio capture/playback, and agent state transitions.
|
|
@@ -147,9 +186,15 @@ export type SessionSnapshot = {
|
|
|
147
186
|
* Uses a subscribe/getSnapshot pattern (compatible with React's
|
|
148
187
|
* `useSyncExternalStore`). Implements `Disposable` for resource cleanup.
|
|
149
188
|
*
|
|
189
|
+
* @sealed Only `createBrowserSession` produces one — see
|
|
190
|
+
* {@link browserSessionBrand}. A test double for a component is a real
|
|
191
|
+
* session with no socket, not an object literal.
|
|
192
|
+
*
|
|
150
193
|
* @public
|
|
151
194
|
*/
|
|
152
195
|
export type BrowserSession = {
|
|
196
|
+
/** The seal — see {@link browserSessionBrand}. */
|
|
197
|
+
readonly [browserSessionBrand]: true;
|
|
153
198
|
/** Return the current immutable state snapshot. */
|
|
154
199
|
getSnapshot(): SessionSnapshot;
|
|
155
200
|
/** Subscribe to state changes. Returns an unsubscribe function. */
|
|
@@ -166,6 +211,8 @@ export type BrowserSession = {
|
|
|
166
211
|
}): void;
|
|
167
212
|
/** Cancel the current agent turn and discard in-flight TTS audio. */
|
|
168
213
|
cancel(): void;
|
|
214
|
+
/** Push-to-talk's three edges — see {@link UserTurnControls}. */
|
|
215
|
+
readonly userTurn: UserTurnControls;
|
|
169
216
|
/**
|
|
170
217
|
* Clear messages, transcripts, and error state while keeping the current
|
|
171
218
|
* connection (unlike `reset()`, which also reconnects).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push-to-talk's three edges on the browser session — `session.userTurn`'s
|
|
3
|
+
* `start`, `commit` and `clear` — for an agent that declares
|
|
4
|
+
* `turnDetection: "manual"`.
|
|
5
|
+
*
|
|
6
|
+
* Split out of `session-core.ts` at the source-length cap. Each is a frame to
|
|
7
|
+
* the server; what the server does with it is `aai-runtime`'s
|
|
8
|
+
* `pipeline-manual-turn.ts`. The two things done HERE rather than waiting a
|
|
9
|
+
* round trip for are the reason these are more than `sendJson` calls: a press
|
|
10
|
+
* stops the agent's queued audio at once, and a discarded turn's caption is
|
|
11
|
+
* cleared because no transcript for it will ever arrive.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
import type { SessionCommand } from "@alexkroman1/aai/protocol";
|
|
16
|
+
import type { SessionStateMachine } from "./session-core-state.ts";
|
|
17
|
+
import type { SessionSnapshot, UserTurnControls } from "./session-core-types.ts";
|
|
18
|
+
/** What the three edges need from the session around them. @internal */
|
|
19
|
+
export type UserTurnDeps = {
|
|
20
|
+
snapshot: () => SessionSnapshot;
|
|
21
|
+
/** A socket is open and can carry a frame. */
|
|
22
|
+
connected: () => boolean;
|
|
23
|
+
/** The local turn boundary — see `bargeIn` in `session-core-types.ts`. */
|
|
24
|
+
bargeIn: () => void;
|
|
25
|
+
agentState: SessionStateMachine;
|
|
26
|
+
updateState: (partial: Partial<SessionSnapshot>) => void;
|
|
27
|
+
sendJson: (msg: SessionCommand) => void;
|
|
28
|
+
};
|
|
29
|
+
/** Build the three push-to-talk edges. @internal */
|
|
30
|
+
export declare function createUserTurnActions(deps: UserTurnDeps): UserTurnControls;
|
package/dist/session-core.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as createBrowserSession } from "./session-core-
|
|
1
|
+
import { t as createBrowserSession } from "./session-core-TOdm_Con.js";
|
|
2
2
|
export { createBrowserSession };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `usePushToTalk` — a hold-to-speak button, for an agent that declares
|
|
3
|
+
* `turnDetection: "manual"`.
|
|
4
|
+
*
|
|
5
|
+
* The three session edges underneath (`session.userTurn`'s `start`, `commit`
|
|
6
|
+
* and `clear`) are one line each; what a button needs on top of them is
|
|
7
|
+
* where hand-written versions go wrong, and every one of the four is a turn
|
|
8
|
+
* that is left OPEN — the microphone live and nothing ever answered:
|
|
9
|
+
*
|
|
10
|
+
* - **The pointer leaves.** A `mouseup` outside the button never reaches it.
|
|
11
|
+
* The handlers here capture the pointer on the way down, so the release
|
|
12
|
+
* arrives wherever it happens; an `onPointerCancel` (a scroll gesture, a
|
|
13
|
+
* system dialog) DISCARDS the turn rather than answering half a sentence.
|
|
14
|
+
* - **The key repeats.** Holding a key fires `keydown` over and over, and each
|
|
15
|
+
* one would re-open the turn. Only the first counts.
|
|
16
|
+
* - **The window loses focus mid-hold.** The `keyup` goes to another window
|
|
17
|
+
* and is never delivered, so the turn is committed on `blur` — what the
|
|
18
|
+
* caller would have done had the release been seen.
|
|
19
|
+
* - **The component unmounts mid-hold.** The turn is discarded on the way out.
|
|
20
|
+
*
|
|
21
|
+
* And one that is a nuisance rather than a stuck turn: the hold key is ignored
|
|
22
|
+
* while focus is in a text field, where Space is a character.
|
|
23
|
+
*/
|
|
24
|
+
/** Options for {@link usePushToTalk}. */
|
|
25
|
+
export type UsePushToTalkOptions = {
|
|
26
|
+
/**
|
|
27
|
+
* The keyboard key that holds the turn open, as a `KeyboardEvent.code` —
|
|
28
|
+
* `"Space"` by default, so the whole page is a walkie-talkie. `false` turns
|
|
29
|
+
* the global key off; the button itself still answers Space and Enter while
|
|
30
|
+
* it has focus.
|
|
31
|
+
*/
|
|
32
|
+
holdKey?: string | false;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* What {@link usePushToTalk} returns.
|
|
36
|
+
*
|
|
37
|
+
* @public
|
|
38
|
+
*/
|
|
39
|
+
export type UsePushToTalkResult = {
|
|
40
|
+
/** Whether a turn is being held open right now — the button is DOWN. */
|
|
41
|
+
talking: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Whether pressing would do anything: the call is live. False before Start
|
|
44
|
+
* and while paused, which is when a button should render disabled.
|
|
45
|
+
*/
|
|
46
|
+
ready: boolean;
|
|
47
|
+
/** Open a turn. Interrupts the agent if it is speaking. Ignored while held. */
|
|
48
|
+
press: () => void;
|
|
49
|
+
/** Close the turn and have the agent answer it. Ignored unless held. */
|
|
50
|
+
release: () => void;
|
|
51
|
+
/** Close the turn and discard it — nothing is answered. Ignored unless held. */
|
|
52
|
+
cancel: () => void;
|
|
53
|
+
/**
|
|
54
|
+
* Spread onto a `<button>`: pointer capture, the keyboard pair, and
|
|
55
|
+
* `aria-pressed`. The handlers are the whole contract — style it however you
|
|
56
|
+
* like.
|
|
57
|
+
*/
|
|
58
|
+
buttonProps: {
|
|
59
|
+
onPointerDown: (event: {
|
|
60
|
+
currentTarget: Element;
|
|
61
|
+
pointerId: number;
|
|
62
|
+
}) => void;
|
|
63
|
+
onPointerUp: () => void;
|
|
64
|
+
onPointerCancel: () => void;
|
|
65
|
+
onKeyDown: (event: {
|
|
66
|
+
key: string;
|
|
67
|
+
repeat: boolean;
|
|
68
|
+
preventDefault(): void;
|
|
69
|
+
}) => void;
|
|
70
|
+
onKeyUp: (event: {
|
|
71
|
+
key: string;
|
|
72
|
+
preventDefault(): void;
|
|
73
|
+
}) => void;
|
|
74
|
+
onContextMenu: (event: {
|
|
75
|
+
preventDefault(): void;
|
|
76
|
+
}) => void;
|
|
77
|
+
disabled: boolean;
|
|
78
|
+
"aria-pressed": boolean;
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Hold-to-speak over the session's push-to-talk methods, with the four ways a
|
|
83
|
+
* turn gets stuck open handled — see this module's doc.
|
|
84
|
+
*
|
|
85
|
+
* Must be used inside the provider `mountClient()` installs, against an agent
|
|
86
|
+
* declaring `turnDetection: "manual"`; any other agent ignores the commands and
|
|
87
|
+
* its server says so once.
|
|
88
|
+
*
|
|
89
|
+
* @example A hold-to-talk button
|
|
90
|
+
* ```tsx
|
|
91
|
+
* import { usePushToTalk } from "@alexkroman1/aai-ui";
|
|
92
|
+
*
|
|
93
|
+
* function TalkButton() {
|
|
94
|
+
* const { talking, buttonProps } = usePushToTalk();
|
|
95
|
+
* return (
|
|
96
|
+
* <button type="button" {...buttonProps}>
|
|
97
|
+
* {talking ? "Listening… release to send" : "Hold to talk (or hold Space)"}
|
|
98
|
+
* </button>
|
|
99
|
+
* );
|
|
100
|
+
* }
|
|
101
|
+
* ```
|
|
102
|
+
*
|
|
103
|
+
* @public
|
|
104
|
+
*/
|
|
105
|
+
export declare function usePushToTalk(options?: UsePushToTalkOptions): UsePushToTalkResult;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alexkroman1/aai-ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "18.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"use-stick-to-bottom": "^1.1.6",
|
|
47
47
|
"use-sync-external-store": "^1.6.0",
|
|
48
48
|
"xstate": "^5.32.6",
|
|
49
|
-
"@alexkroman1/aai": "
|
|
49
|
+
"@alexkroman1/aai": "18.0.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@tailwindcss/vite": "^4.3.3",
|