@alexkroman1/aai-ui 16.1.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/README.md +3 -2
- package/dist/_page-shell.d.ts +11 -0
- package/dist/{chat-view-DsylXJsw.js → chat-view-P12pHKQU.js} +1 -1
- package/dist/{client-config-DJQHnYjm.js → client-config-CFhkMkmv.js} +15 -13
- package/dist/client-config.d.ts +14 -13
- package/dist/components/chat-view.js +1 -1
- package/dist/components/controls.js +1 -1
- package/dist/components/message-list.js +1 -1
- package/dist/components/tool-call-block.js +1 -1
- package/dist/context.d.ts +27 -5
- package/dist/context.js +3 -3
- package/dist/default-client/assets/index-5XG8PqGw.css +2 -0
- package/dist/default-client/assets/index-tc-nAWq3.js +121 -0
- package/dist/default-client/index.html +2 -2
- package/dist/default-client.d.ts +21 -0
- package/dist/define-client.js +4 -4
- package/dist/hooks.d.ts +19 -9
- package/dist/hooks.js +1 -1
- package/dist/index.d.ts +17 -13
- package/dist/index.js +734 -433
- package/dist/internal.js +3 -3
- package/dist/{message-list-BLyPRS1Q.js → message-list-CJX560b5.js} +1 -1
- package/dist/page.d.ts +47 -21
- package/dist/{session-core-gwePM95B.js → session-core-TOdm_Con.js} +424 -174
- package/dist/session-core-audio-effects.d.ts +26 -0
- package/dist/session-core-audio-setup.d.ts +38 -31
- package/dist/session-core-audio-state.d.ts +152 -0
- package/dist/session-core-messages.d.ts +9 -10
- package/dist/session-core-types.d.ts +60 -21
- 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/dist/{use-workflow-run-CNahvlgL.js → use-workflow-run-ZqJWJn2-.js} +24 -11
- package/dist/workflow-client.d.ts +26 -12
- package/package.json +2 -2
- package/dist/default-client/assets/index-CMh4E-Ci.css +0 -2
- package/dist/default-client/assets/index-eOmPq2sS.js +0 -284
- package/dist/{tool-call-block-C2t_5fpp.js → tool-call-block-4gXoL_eP.js} +1 -1
- package/dist/{url-chips-D62RTz7v.js → url-chips-0n35fPJY.js} +1 -1
package/dist/index.js
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
import { a as AGENT_STATE_LABELS, i as SessionErrorBanner, n as ConsoleShell, r as SessionStateDot, t as ChatView } from "./chat-view-
|
|
2
|
-
import { n as
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { a as AGENT_STATE_LABELS, i as SessionErrorBanner, n as ConsoleShell, r as SessionStateDot, t as ChatView } from "./chat-view-P12pHKQU.js";
|
|
2
|
+
import { n as setPageTitle } from "./_utils-DnQDM9Uy.js";
|
|
3
|
+
import { n as fetchClientConfig } from "./client-config-CFhkMkmv.js";
|
|
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, useSessionCore, useSessionError, useSessionSelector, useSessionStatus, useTheme } from "./context.js";
|
|
5
6
|
import { a as inkTint, i as focusRingStyle, n as FOCUS_RING } from "./_colors-CpZO-88A.js";
|
|
6
7
|
import { Button } from "./components/button.js";
|
|
7
|
-
import {
|
|
8
|
-
import { a as useFlash, i as useCopy } from "./url-chips-D62RTz7v.js";
|
|
8
|
+
import { a as useFlash, i as useCopy } from "./url-chips-0n35fPJY.js";
|
|
9
9
|
import { Controls } from "./components/controls.js";
|
|
10
10
|
import { n as useUserTranscript } from "./use-user-transcript-uyHhzy4d.js";
|
|
11
|
-
import { n as ToolCallRow } from "./tool-call-block-
|
|
11
|
+
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
|
-
import { a as useWorkflowProgress, c as isTerminal, o as useWorkflowApiRef, r as useWorkflowRun, s as createWorkflowApi } from "./use-workflow-run-
|
|
15
|
-
import { i as urlSlot, n as storageGet, r as storageSet, t as createBrowserSession } from "./session-core-
|
|
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-TOdm_Con.js";
|
|
16
16
|
import { mountClient, mountRoot, resolveContainer } from "./define-client.js";
|
|
17
17
|
import { useAgentState, useEvent, useToolCallStart, useToolResult } from "./hooks.js";
|
|
18
|
+
import { errorMessage } from "@alexkroman1/aai";
|
|
18
19
|
import clsx from "clsx";
|
|
19
20
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
20
21
|
import { Fragment as Fragment$1, createContext, createElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
21
|
-
import { errorMessage } from "@alexkroman1/aai";
|
|
22
22
|
import { formatBytes, isRecord, omitUndefined } from "@alexkroman1/aai/utils";
|
|
23
23
|
import { createEpoch } from "@alexkroman1/aai/internal";
|
|
24
24
|
//#region src/components/audio-result.tsx
|
|
@@ -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,
|
|
@@ -1747,447 +1747,142 @@ function WorkflowRunPanel({ run, statusLabels, onClear, api, live, children, cla
|
|
|
1747
1747
|
});
|
|
1748
1748
|
}
|
|
1749
1749
|
//#endregion
|
|
1750
|
-
//#region src/
|
|
1751
|
-
/** @jsxImportSource react */
|
|
1750
|
+
//#region src/_recover-run.ts
|
|
1752
1751
|
/**
|
|
1753
|
-
*
|
|
1754
|
-
*
|
|
1755
|
-
* The twin of `mountClient()` for an agent whose front door is a form rather than a
|
|
1756
|
-
* microphone (`workflowApp()`). It is a separate entry rather than
|
|
1757
|
-
* an option on `mountClient()` because of what `mountClient()` unavoidably does: it
|
|
1758
|
-
* constructs a `BrowserSession`, which owns a WebSocket URL provider, an audio
|
|
1759
|
-
* graph, and a microphone request. A flag would have to make all of that
|
|
1760
|
-
* conditional, and every session hook would then have to answer "what does this
|
|
1761
|
-
* mean with no session?" — so the honest split is two mounts. A page that wants
|
|
1762
|
-
* voice uses `mountClient()`; a page that wants neither audio nor a socket uses this.
|
|
1752
|
+
* Finding a run again when the page has lost its id.
|
|
1763
1753
|
*
|
|
1764
|
-
*
|
|
1765
|
-
*
|
|
1766
|
-
*
|
|
1767
|
-
*
|
|
1768
|
-
|
|
1769
|
-
/**
|
|
1770
|
-
* Mount a page for an agent whose work happens in workflows.
|
|
1754
|
+
* A run is durable and a page is not — which `useWorkflowRun`'s doc says, and
|
|
1755
|
+
* which was only half true of the hooks above it: the run id lived in plain
|
|
1756
|
+
* `useState`, so a refresh (or a same-tab navigation, or a crashed tab) left a
|
|
1757
|
+
* live run with nothing anywhere able to name it. The run really did continue;
|
|
1758
|
+
* the person really could not get back to it.
|
|
1771
1759
|
*
|
|
1772
|
-
*
|
|
1773
|
-
*
|
|
1774
|
-
* (
|
|
1760
|
+
* `StartOptions.key` is the handle that survives that, and it always was — a
|
|
1761
|
+
* caller's own name for a run, indexed by the agent, read back with
|
|
1762
|
+
* `find(workflow, key)`. What was missing is the two lines that ASK. This is
|
|
1763
|
+
* them, plus the four decisions they turn out to carry.
|
|
1775
1764
|
*
|
|
1776
|
-
*
|
|
1777
|
-
* ```tsx
|
|
1778
|
-
* import { createWorkflowApi, mountPage, useWorkflowRun } from "@alexkroman1/aai-ui";
|
|
1779
|
-
* import { useState } from "react";
|
|
1765
|
+
* ## It is a MOUNT-time act, not "whenever there is no run"
|
|
1780
1766
|
*
|
|
1781
|
-
*
|
|
1782
|
-
*
|
|
1767
|
+
* The tempting spelling is "if we hold no run id, look one up", and it breaks
|
|
1768
|
+
* `reset()`: a form put back to its initial state holds no run id, so the next
|
|
1769
|
+
* pass would re-adopt the very run the person had just dismissed — a Clear
|
|
1770
|
+
* button that clears nothing. So the lookup runs once per mount (and again only
|
|
1771
|
+
* if the KEY changes, which is a different person's run), and every later
|
|
1772
|
+
* absence of a run id is taken at face value.
|
|
1783
1773
|
*
|
|
1784
|
-
*
|
|
1785
|
-
* const [runId, setRunId] = useState<string>();
|
|
1786
|
-
* const { run } = useWorkflowRun(runId, { api });
|
|
1787
|
-
* return (
|
|
1788
|
-
* <button
|
|
1789
|
-
* type="button"
|
|
1790
|
-
* onClick={() => void api.start("digest", { topic: "ai" }).then(setRunId)}
|
|
1791
|
-
* >
|
|
1792
|
-
* {run ? run.status : "Start"}
|
|
1793
|
-
* </button>
|
|
1794
|
-
* );
|
|
1795
|
-
* }
|
|
1774
|
+
* ## The lookup NEVER wins a race against a submit
|
|
1796
1775
|
*
|
|
1797
|
-
*
|
|
1798
|
-
*
|
|
1776
|
+
* A person who reloads and immediately submits has started the run they want,
|
|
1777
|
+
* and an answer that was already in flight names an older one. The caller
|
|
1778
|
+
* therefore adopts through `current ?? found`: the recovered id fills an empty
|
|
1779
|
+
* slot and never replaces a full one.
|
|
1799
1780
|
*
|
|
1800
|
-
*
|
|
1781
|
+
* ## A failed lookup is REPORTED
|
|
1801
1782
|
*
|
|
1802
|
-
*
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
}
|
|
1809
|
-
//#endregion
|
|
1810
|
-
//#region src/use-download-url.ts
|
|
1811
|
-
/**
|
|
1812
|
-
* `useDownloadUrl` — an upload id a run produced, as something `<audio>`,
|
|
1813
|
-
* `<img>` or `<a download>` will accept.
|
|
1783
|
+
* The alternative is a page that quietly shows an empty form to somebody whose
|
|
1784
|
+
* run is live, who then starts a second one — the duplicated work the key
|
|
1785
|
+
* exists to prevent, and on a workflow app that is real money. A person who has
|
|
1786
|
+
* never run anything pays a banner they can ignore. Same trade as
|
|
1787
|
+
* `useWorkflows`, for the same reason: an empty answer here is a confident
|
|
1788
|
+
* false statement.
|
|
1814
1789
|
*
|
|
1815
|
-
*
|
|
1816
|
-
* same bearer every workflow route does, and neither `<audio src>` nor
|
|
1817
|
-
* `<a href>` can send one. So every page that plays back what a run WROTE ends
|
|
1818
|
-
* up at the same four lines — `download` → `createObjectURL` → state — and the
|
|
1819
|
-
* two that are really the point are the two the four lines are wrapped in:
|
|
1790
|
+
* ## It is ON by default, and it used to be opt-in
|
|
1820
1791
|
*
|
|
1821
|
-
*
|
|
1822
|
-
*
|
|
1823
|
-
*
|
|
1824
|
-
*
|
|
1825
|
-
*
|
|
1826
|
-
*
|
|
1827
|
-
*
|
|
1828
|
-
* that looks like a right one.
|
|
1792
|
+
* The argument for opt-in was that a `key` on its own means only "record this
|
|
1793
|
+
* with the run" — which is what a voice agent's `ctx.workflows.start({ key })`
|
|
1794
|
+
* means, there being no page to put a run back on. A FORM is the other case: it
|
|
1795
|
+
* is the page, and losing the run is the thing it cannot recover from. Six of
|
|
1796
|
+
* six page templates wrote `useRunKey()` and `recover: true` together, which is
|
|
1797
|
+
* the same shape `session-resume-store.ts` names on the voice side — a default
|
|
1798
|
+
* in the wrong place — so `useWorkflowSubmit` now mints the key and asks.
|
|
1829
1799
|
*
|
|
1830
|
-
*
|
|
1831
|
-
*
|
|
1832
|
-
* checking `url === undefined && error === undefined`, which reads "idle" and
|
|
1833
|
-
* "downloading" as the same thing — so this reports it.
|
|
1800
|
+
* `enabled` remains, because `recover: false` remains: a page whose form must
|
|
1801
|
+
* always open empty says so, and then nothing here runs.
|
|
1834
1802
|
*/
|
|
1835
|
-
/** No id: nothing pending, nothing to show. A shared object so `setState` no-ops. */
|
|
1836
|
-
const IDLE = { pending: false };
|
|
1837
|
-
/** Bytes in flight. Shared for the same reason as {@link IDLE}. */
|
|
1838
|
-
const PENDING = { pending: true };
|
|
1839
1803
|
/**
|
|
1840
|
-
*
|
|
1841
|
-
*
|
|
1842
|
-
* @param uploadId - The id a completed run reported, or `undefined` before one
|
|
1843
|
-
* exists — which is what a page passes straight through while it waits, and
|
|
1844
|
-
* reports as idle rather than pending.
|
|
1845
|
-
* @param options - See {@link UseDownloadUrlOptions}.
|
|
1846
|
-
* @returns See {@link UseDownloadUrlResult}.
|
|
1847
|
-
*
|
|
1848
|
-
* @example
|
|
1849
|
-
* ```tsx no-check
|
|
1850
|
-
* import { useDownloadUrl, useWorkflowSubmit } from "@alexkroman1/aai-ui";
|
|
1851
|
-
* import type { spokenSummary } from "./agent.ts";
|
|
1804
|
+
* Look up the newest run for a key, once, as the component mounts.
|
|
1852
1805
|
*
|
|
1853
|
-
*
|
|
1854
|
-
*
|
|
1855
|
-
*
|
|
1856
|
-
*
|
|
1857
|
-
* if (audio.pending) return <p>Fetching audio…</p>;
|
|
1858
|
-
* if (audio.error !== undefined) return <p role="alert">{audio.error}</p>;
|
|
1859
|
-
* return audio.url === undefined ? null : (
|
|
1860
|
-
* <a href={audio.url} download="summary.mp3">
|
|
1861
|
-
* Download
|
|
1862
|
-
* </a>
|
|
1863
|
-
* );
|
|
1864
|
-
* }
|
|
1865
|
-
* ```
|
|
1806
|
+
* @param opts - See {@link RecoverRunOptions}.
|
|
1807
|
+
* @returns Whether the lookup is still out. A caller folds it into its own
|
|
1808
|
+
* `pending`, because a form offering Submit while a live run is arriving is a
|
|
1809
|
+
* form inviting a second one.
|
|
1866
1810
|
*
|
|
1867
|
-
* @
|
|
1811
|
+
* @internal
|
|
1868
1812
|
*/
|
|
1869
|
-
function
|
|
1870
|
-
const
|
|
1871
|
-
const
|
|
1813
|
+
function useRecoveredRun(opts) {
|
|
1814
|
+
const { workflow, key, enabled, getClient } = opts;
|
|
1815
|
+
const [recovering, setRecovering] = useState(enabled);
|
|
1816
|
+
const handlers = useRef(opts);
|
|
1817
|
+
handlers.current = opts;
|
|
1872
1818
|
useEffect(() => {
|
|
1873
|
-
if (
|
|
1874
|
-
setState(IDLE);
|
|
1875
|
-
return;
|
|
1876
|
-
}
|
|
1819
|
+
if (!enabled) return;
|
|
1877
1820
|
let cancelled = false;
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
getClient().download(uploadId).then((blob) => {
|
|
1821
|
+
setRecovering(true);
|
|
1822
|
+
getClient().find(workflow, key, { limit: 1 }).then((found) => {
|
|
1881
1823
|
if (cancelled) return;
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
pending: false
|
|
1886
|
-
});
|
|
1824
|
+
const newest = found[0];
|
|
1825
|
+
if (newest !== void 0) handlers.current.onFound(newest.runId);
|
|
1826
|
+
setRecovering(false);
|
|
1887
1827
|
}).catch((err) => {
|
|
1888
|
-
if (
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
});
|
|
1828
|
+
if (cancelled) return;
|
|
1829
|
+
handlers.current.onError(errorMessage(err));
|
|
1830
|
+
setRecovering(false);
|
|
1892
1831
|
});
|
|
1893
1832
|
return () => {
|
|
1894
1833
|
cancelled = true;
|
|
1895
|
-
if (objectUrl !== void 0) URL.revokeObjectURL(objectUrl);
|
|
1896
1834
|
};
|
|
1897
|
-
}, [
|
|
1898
|
-
|
|
1835
|
+
}, [
|
|
1836
|
+
enabled,
|
|
1837
|
+
key,
|
|
1838
|
+
workflow,
|
|
1839
|
+
getClient
|
|
1840
|
+
]);
|
|
1841
|
+
return recovering;
|
|
1899
1842
|
}
|
|
1900
1843
|
//#endregion
|
|
1901
|
-
//#region src/
|
|
1844
|
+
//#region src/_run-controls.ts
|
|
1902
1845
|
/**
|
|
1903
|
-
* The
|
|
1904
|
-
*
|
|
1905
|
-
* `useWorkflowSubmit` is what makes a run survivable — the run id is that
|
|
1906
|
-
* hook's own state, so a refresh loses it while the run carries on — and the
|
|
1907
|
-
* `key` is what it looks the run up BY, because it is a lookup CAPABILITY:
|
|
1908
|
-
* there is no per-user filtering behind `find`, so the key IS the scoping
|
|
1909
|
-
* mechanism. Choosing one is easy to get wrong in three separate ways, and six
|
|
1910
|
-
* shipped templates had each written the same twenty lines to get it right.
|
|
1911
|
-
* This is those lines.
|
|
1912
|
-
*
|
|
1913
|
-
* **`useWorkflowSubmit` now mints one for itself** ({@link useDefaultRunKey}),
|
|
1914
|
-
* so a page resumes its own run across a reload with nothing written at the
|
|
1915
|
-
* call site — six of six page templates passed `useRunKey()` and
|
|
1916
|
-
* `recover: true`, which is a default in the wrong place. The hook stays
|
|
1917
|
-
* PUBLIC for the page that wants to choose: an app with accounts passes the
|
|
1918
|
-
* ACCOUNT's own id instead, and a run then follows the person to a new device,
|
|
1919
|
-
* which is a promise only a login can keep; a page whose run outlives the tab
|
|
1920
|
-
* passes `useRunKey({ storage: "local" })`.
|
|
1846
|
+
* The two things a page does TO a run it started, bound to the run it has.
|
|
1921
1847
|
*
|
|
1922
|
-
*
|
|
1848
|
+
* `useWorkflowSubmit` and `useWorkflowStream` both hold a run id and neither
|
|
1849
|
+
* handed it back, so a page that wanted "send it now" or "stop" had to hold an
|
|
1850
|
+
* `api` of its own purely to write `api.wake(runId)` — which is the whole reason
|
|
1851
|
+
* the two raw-primitive template pages keep a client at module scope. That is a
|
|
1852
|
+
* page carrying the transport to make up for a hook withholding its own state.
|
|
1923
1853
|
*
|
|
1924
|
-
*
|
|
1925
|
-
*
|
|
1926
|
-
*
|
|
1927
|
-
*
|
|
1928
|
-
*
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
*
|
|
1932
|
-
* run, and the load after it finds the run by producing the same key.
|
|
1854
|
+
* Both calls answer rather than fail when there is nothing to act on — `0`
|
|
1855
|
+
* sleeps ended, `false` this call did not end it — which is the SDK's own
|
|
1856
|
+
* contract for them (two tabs pressing Stop is ordinary), and it is what lets
|
|
1857
|
+
* the no-run case be the same answer rather than a special one a caller has to
|
|
1858
|
+
* branch on.
|
|
1859
|
+
*/
|
|
1860
|
+
/**
|
|
1861
|
+
* Bind `wake` and `cancel` to whatever run the hook is currently following.
|
|
1933
1862
|
*
|
|
1934
|
-
*
|
|
1935
|
-
*
|
|
1936
|
-
*
|
|
1937
|
-
* what a leaked one buys is somebody else's work — reading it, and `cancel()` on
|
|
1938
|
-
* it. An app with accounts should pass the ACCOUNT's own id here instead, and
|
|
1939
|
-
* then a run follows the person to a new device, which is a promise only a login
|
|
1940
|
-
* can keep.
|
|
1863
|
+
* @param runId - The live run, or `undefined` before one exists.
|
|
1864
|
+
* @param getClient - The stable getter from `useWorkflowApiRef`.
|
|
1865
|
+
* @returns Two callbacks, stable while `runId` is.
|
|
1941
1866
|
*
|
|
1942
|
-
*
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
*
|
|
1960
|
-
*
|
|
1961
|
-
* This key needs no validation, and it is worth saying why, because it is the
|
|
1962
|
-
* exception: any string is a legal key, so a value from storage can only fail to
|
|
1963
|
-
* match a run. A page that remembers something more — which MODE submitted, say
|
|
1964
|
-
* — is remembering a value it will turn into a name, and storage hands back a
|
|
1965
|
-
* string some earlier version of that page wrote: a renamed mode, a hand-edited
|
|
1966
|
-
* value, a slot another app on the origin happens to share. Unchecked, that
|
|
1967
|
-
* starts a run called `undefined` and answers a 400 nobody typed. Check it
|
|
1968
|
-
* against the page's own list on the way out (`recalledMode` in
|
|
1969
|
-
* `transcription-workflow/recover.ts` is the worked example) — the recall is the
|
|
1970
|
-
* page's, the validation is not optional.
|
|
1971
|
-
*
|
|
1972
|
-
* ## The slot is keyed by the page's own URL
|
|
1973
|
-
*
|
|
1974
|
-
* Every deployed agent is served from one origin at `/:slug/`, so a fixed name
|
|
1975
|
-
* would have two agents scaffolded from the same template recover each other's
|
|
1976
|
-
* runs. The key is the page's own directory — resolved through `"./"`, which
|
|
1977
|
-
* drops the query and the hash, since a reload carrying `?foo` or `#bar` has to
|
|
1978
|
-
* find the same key. Same call `session-resume-store.ts` makes, for the same
|
|
1979
|
-
* reason.
|
|
1980
|
-
*
|
|
1981
|
-
* One key per PAGE is right even for a page driving several workflows: `find` is
|
|
1982
|
-
* scoped by workflow as well as by key, so three hooks sharing one key recover
|
|
1983
|
-
* three separate runs. `transcription-workflow` is that page.
|
|
1984
|
-
*
|
|
1985
|
-
* Every access is guarded. Storage THROWS outright in some contexts (Safari
|
|
1986
|
-
* private mode, an iframe blocked by policy) and is ABSENT in others (any
|
|
1987
|
-
* server-side render), and a page that cannot remember its key must degrade to
|
|
1988
|
-
* the behaviour it would have had anyway — one run per load — rather than
|
|
1989
|
-
* failing to render.
|
|
1990
|
-
*/
|
|
1991
|
-
/** Where a run key lives, namespaced like this package's two other stores. */
|
|
1992
|
-
const PREFIX$1 = "aai:run-key:";
|
|
1993
|
-
/** This page's own slot — see "The slot is keyed by the page's own URL". */
|
|
1994
|
-
function slotFor() {
|
|
1995
|
-
return urlSlot(PREFIX$1, "./");
|
|
1996
|
-
}
|
|
1997
|
-
/**
|
|
1998
|
-
* Read the key this page already has, or mint and remember one.
|
|
1999
|
-
*
|
|
2000
|
-
* Not exported: a page that wants a key wants it for the life of a component,
|
|
2001
|
-
* which is what the hook is. Calling this per render would mint a fresh key and
|
|
2002
|
-
* hand `recover` one nothing was ever started under.
|
|
2003
|
-
*/
|
|
2004
|
-
function mintRunKey(storage) {
|
|
2005
|
-
const slot = slotFor();
|
|
2006
|
-
const stored = storageGet(storage, slot);
|
|
2007
|
-
if (stored !== void 0) return stored;
|
|
2008
|
-
const minted = crypto.randomUUID();
|
|
2009
|
-
storageSet(storage, slot, minted);
|
|
2010
|
-
return minted;
|
|
2011
|
-
}
|
|
2012
|
-
/**
|
|
2013
|
-
* The key `useWorkflowSubmit` uses when the page named none.
|
|
2014
|
-
*
|
|
2015
|
-
* Two things it does that a plain `useRunKey()` at the call site cannot, and
|
|
2016
|
-
* both are about a page that DID name one:
|
|
2017
|
-
*
|
|
2018
|
-
* - **It mints nothing when the caller has a key.** Minting writes to storage,
|
|
2019
|
-
* so an unconditional `useRunKey()` inside the hook would leave a slot behind
|
|
2020
|
-
* on every page that passes an account id and never reads it back.
|
|
2021
|
-
* - **It stays reactive to the caller's key.** A key that arrives late — an
|
|
2022
|
-
* account id resolved after a login — must reach the lookup, which re-asks on
|
|
2023
|
-
* a changed key by design; freezing it into `useState` would pin the page to
|
|
2024
|
-
* whatever it held on its first render.
|
|
2025
|
-
*
|
|
2026
|
-
* The minted half is still frozen for the component's life, which is what
|
|
2027
|
-
* `useRunKey` freezes it for: a fresh key per render would record every run
|
|
2028
|
-
* under a name the next load cannot produce.
|
|
2029
|
-
*
|
|
2030
|
-
* @param explicit - The caller's own key, or undefined for a page with none.
|
|
2031
|
-
* @returns The key to record runs under and look them up by.
|
|
2032
|
-
*
|
|
2033
|
-
* @internal
|
|
2034
|
-
*/
|
|
2035
|
-
function useDefaultRunKey(explicit) {
|
|
2036
|
-
const [minted] = useState(() => explicit === void 0 ? mintRunKey("session") : "");
|
|
2037
|
-
return explicit ?? minted;
|
|
2038
|
-
}
|
|
2039
|
-
/**
|
|
2040
|
-
* A lookup key for `useWorkflowSubmit({ key })`, stable across reloads.
|
|
2041
|
-
*
|
|
2042
|
-
* @param options - See the module doc for the whole argument. The storage kind
|
|
2043
|
-
* is read once, when the key is minted: a value that changed afterwards would
|
|
2044
|
-
* be asking to move a key that has already been recorded with a run.
|
|
2045
|
-
* @returns The key to record runs under and to look them up by — the same one
|
|
2046
|
-
* for the life of the component, and for the next load in the same tab (or the
|
|
2047
|
-
* same browser, under `"local"`).
|
|
2048
|
-
*/
|
|
2049
|
-
function useRunKey(options = {}) {
|
|
2050
|
-
const { storage = "session" } = options;
|
|
2051
|
-
const [key] = useState(() => mintRunKey(storage));
|
|
2052
|
-
return key;
|
|
2053
|
-
}
|
|
2054
|
-
//#endregion
|
|
2055
|
-
//#region src/_recover-run.ts
|
|
2056
|
-
/**
|
|
2057
|
-
* Finding a run again when the page has lost its id.
|
|
2058
|
-
*
|
|
2059
|
-
* A run is durable and a page is not — which `useWorkflowRun`'s doc says, and
|
|
2060
|
-
* which was only half true of the hooks above it: the run id lived in plain
|
|
2061
|
-
* `useState`, so a refresh (or a same-tab navigation, or a crashed tab) left a
|
|
2062
|
-
* live run with nothing anywhere able to name it. The run really did continue;
|
|
2063
|
-
* the person really could not get back to it.
|
|
2064
|
-
*
|
|
2065
|
-
* `StartOptions.key` is the handle that survives that, and it always was — a
|
|
2066
|
-
* caller's own name for a run, indexed by the agent, read back with
|
|
2067
|
-
* `find(workflow, key)`. What was missing is the two lines that ASK. This is
|
|
2068
|
-
* them, plus the four decisions they turn out to carry.
|
|
2069
|
-
*
|
|
2070
|
-
* ## It is a MOUNT-time act, not "whenever there is no run"
|
|
2071
|
-
*
|
|
2072
|
-
* The tempting spelling is "if we hold no run id, look one up", and it breaks
|
|
2073
|
-
* `reset()`: a form put back to its initial state holds no run id, so the next
|
|
2074
|
-
* pass would re-adopt the very run the person had just dismissed — a Clear
|
|
2075
|
-
* button that clears nothing. So the lookup runs once per mount (and again only
|
|
2076
|
-
* if the KEY changes, which is a different person's run), and every later
|
|
2077
|
-
* absence of a run id is taken at face value.
|
|
2078
|
-
*
|
|
2079
|
-
* ## The lookup NEVER wins a race against a submit
|
|
2080
|
-
*
|
|
2081
|
-
* A person who reloads and immediately submits has started the run they want,
|
|
2082
|
-
* and an answer that was already in flight names an older one. The caller
|
|
2083
|
-
* therefore adopts through `current ?? found`: the recovered id fills an empty
|
|
2084
|
-
* slot and never replaces a full one.
|
|
2085
|
-
*
|
|
2086
|
-
* ## A failed lookup is REPORTED
|
|
2087
|
-
*
|
|
2088
|
-
* The alternative is a page that quietly shows an empty form to somebody whose
|
|
2089
|
-
* run is live, who then starts a second one — the duplicated work the key
|
|
2090
|
-
* exists to prevent, and on a workflow app that is real money. A person who has
|
|
2091
|
-
* never run anything pays a banner they can ignore. Same trade as
|
|
2092
|
-
* `useWorkflows`, for the same reason: an empty answer here is a confident
|
|
2093
|
-
* false statement.
|
|
2094
|
-
*
|
|
2095
|
-
* ## It is ON by default, and it used to be opt-in
|
|
2096
|
-
*
|
|
2097
|
-
* The argument for opt-in was that a `key` on its own means only "record this
|
|
2098
|
-
* with the run" — which is what a voice agent's `ctx.workflows.start({ key })`
|
|
2099
|
-
* means, there being no page to put a run back on. A FORM is the other case: it
|
|
2100
|
-
* is the page, and losing the run is the thing it cannot recover from. Six of
|
|
2101
|
-
* six page templates wrote `useRunKey()` and `recover: true` together, which is
|
|
2102
|
-
* the same shape `session-resume-store.ts` names on the voice side — a default
|
|
2103
|
-
* in the wrong place — so `useWorkflowSubmit` now mints the key and asks.
|
|
2104
|
-
*
|
|
2105
|
-
* `enabled` remains, because `recover: false` remains: a page whose form must
|
|
2106
|
-
* always open empty says so, and then nothing here runs.
|
|
2107
|
-
*/
|
|
2108
|
-
/**
|
|
2109
|
-
* Look up the newest run for a key, once, as the component mounts.
|
|
2110
|
-
*
|
|
2111
|
-
* @param opts - See {@link RecoverRunOptions}.
|
|
2112
|
-
* @returns Whether the lookup is still out. A caller folds it into its own
|
|
2113
|
-
* `pending`, because a form offering Submit while a live run is arriving is a
|
|
2114
|
-
* form inviting a second one.
|
|
2115
|
-
*
|
|
2116
|
-
* @internal
|
|
2117
|
-
*/
|
|
2118
|
-
function useRecoveredRun(opts) {
|
|
2119
|
-
const { workflow, key, enabled, getClient } = opts;
|
|
2120
|
-
const [recovering, setRecovering] = useState(enabled);
|
|
2121
|
-
const handlers = useRef(opts);
|
|
2122
|
-
handlers.current = opts;
|
|
2123
|
-
useEffect(() => {
|
|
2124
|
-
if (!enabled) return;
|
|
2125
|
-
let cancelled = false;
|
|
2126
|
-
setRecovering(true);
|
|
2127
|
-
getClient().find(workflow, key, { limit: 1 }).then((found) => {
|
|
2128
|
-
if (cancelled) return;
|
|
2129
|
-
const newest = found[0];
|
|
2130
|
-
if (newest !== void 0) handlers.current.onFound(newest.runId);
|
|
2131
|
-
setRecovering(false);
|
|
2132
|
-
}).catch((err) => {
|
|
2133
|
-
if (cancelled) return;
|
|
2134
|
-
handlers.current.onError(errorMessage(err));
|
|
2135
|
-
setRecovering(false);
|
|
2136
|
-
});
|
|
2137
|
-
return () => {
|
|
2138
|
-
cancelled = true;
|
|
2139
|
-
};
|
|
2140
|
-
}, [
|
|
2141
|
-
enabled,
|
|
2142
|
-
key,
|
|
2143
|
-
workflow,
|
|
2144
|
-
getClient
|
|
2145
|
-
]);
|
|
2146
|
-
return recovering;
|
|
2147
|
-
}
|
|
2148
|
-
//#endregion
|
|
2149
|
-
//#region src/_run-controls.ts
|
|
2150
|
-
/**
|
|
2151
|
-
* The two things a page does TO a run it started, bound to the run it has.
|
|
2152
|
-
*
|
|
2153
|
-
* `useWorkflowSubmit` and `useWorkflowStream` both hold a run id and neither
|
|
2154
|
-
* handed it back, so a page that wanted "send it now" or "stop" had to hold an
|
|
2155
|
-
* `api` of its own purely to write `api.wake(runId)` — which is the whole reason
|
|
2156
|
-
* the two raw-primitive template pages keep a client at module scope. That is a
|
|
2157
|
-
* page carrying the transport to make up for a hook withholding its own state.
|
|
2158
|
-
*
|
|
2159
|
-
* Both calls answer rather than fail when there is nothing to act on — `0`
|
|
2160
|
-
* sleeps ended, `false` this call did not end it — which is the SDK's own
|
|
2161
|
-
* contract for them (two tabs pressing Stop is ordinary), and it is what lets
|
|
2162
|
-
* the no-run case be the same answer rather than a special one a caller has to
|
|
2163
|
-
* branch on.
|
|
2164
|
-
*/
|
|
2165
|
-
/**
|
|
2166
|
-
* Bind `wake` and `cancel` to whatever run the hook is currently following.
|
|
2167
|
-
*
|
|
2168
|
-
* @param runId - The live run, or `undefined` before one exists.
|
|
2169
|
-
* @param getClient - The stable getter from `useWorkflowApiRef`.
|
|
2170
|
-
* @returns Two callbacks, stable while `runId` is.
|
|
2171
|
-
*
|
|
2172
|
-
* @internal
|
|
2173
|
-
*/
|
|
2174
|
-
function useRunControls(runId, getClient) {
|
|
2175
|
-
return {
|
|
2176
|
-
wake: useCallback(async () => {
|
|
2177
|
-
if (runId === void 0) return 0;
|
|
2178
|
-
return await getClient().wake(runId);
|
|
2179
|
-
}, [runId, getClient]),
|
|
2180
|
-
cancel: useCallback(async () => {
|
|
2181
|
-
if (runId === void 0) return false;
|
|
2182
|
-
return await getClient().cancel(runId);
|
|
2183
|
-
}, [runId, getClient])
|
|
2184
|
-
};
|
|
2185
|
-
}
|
|
2186
|
-
//#endregion
|
|
2187
|
-
//#region src/_upload-pause.ts
|
|
2188
|
-
/**
|
|
2189
|
-
* The two buttons a page puts over a live upload, bound to whichever gate the
|
|
2190
|
-
* submission is currently holding.
|
|
1867
|
+
* @internal
|
|
1868
|
+
*/
|
|
1869
|
+
function useRunControls(runId, getClient) {
|
|
1870
|
+
return {
|
|
1871
|
+
wake: useCallback(async () => {
|
|
1872
|
+
if (runId === void 0) return 0;
|
|
1873
|
+
return await getClient().wake(runId);
|
|
1874
|
+
}, [runId, getClient]),
|
|
1875
|
+
cancel: useCallback(async () => {
|
|
1876
|
+
if (runId === void 0) return false;
|
|
1877
|
+
return await getClient().cancel(runId);
|
|
1878
|
+
}, [runId, getClient])
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
//#endregion
|
|
1882
|
+
//#region src/_upload-pause.ts
|
|
1883
|
+
/**
|
|
1884
|
+
* The two buttons a page puts over a live upload, bound to whichever gate the
|
|
1885
|
+
* submission is currently holding.
|
|
2191
1886
|
*
|
|
2192
1887
|
* `useWorkflowSubmit` and `useWorkflowStream` both own an {@link UploadGate} and
|
|
2193
1888
|
* both reported the pause the same way: park the gate, then fold `paused` into
|
|
@@ -2317,7 +2012,7 @@ function useSubmissionState() {
|
|
|
2317
2012
|
* under a blocking policy, and an upload that cannot be REMEMBERED must degrade
|
|
2318
2013
|
* to the upload we would have done anyway rather than failing to start.
|
|
2319
2014
|
*/
|
|
2320
|
-
const PREFIX = "aai:upload:";
|
|
2015
|
+
const PREFIX$1 = "aai:upload:";
|
|
2321
2016
|
/**
|
|
2322
2017
|
* How many ids one form keeps.
|
|
2323
2018
|
*
|
|
@@ -2329,7 +2024,7 @@ const PREFIX = "aai:upload:";
|
|
|
2329
2024
|
const MAX_REMEMBERED = 32;
|
|
2330
2025
|
/** One form's slot in storage. */
|
|
2331
2026
|
function keyFor(scope) {
|
|
2332
|
-
return `${PREFIX}${scope}`;
|
|
2027
|
+
return `${PREFIX$1}${scope}`;
|
|
2333
2028
|
}
|
|
2334
2029
|
/**
|
|
2335
2030
|
* What names this file across a reload.
|
|
@@ -2736,16 +2431,170 @@ function coalesceUploadReports(set) {
|
|
|
2736
2431
|
};
|
|
2737
2432
|
}
|
|
2738
2433
|
//#endregion
|
|
2739
|
-
//#region src/use-
|
|
2434
|
+
//#region src/use-run-key.ts
|
|
2740
2435
|
/**
|
|
2741
|
-
* The
|
|
2742
|
-
*
|
|
2743
|
-
* `
|
|
2744
|
-
*
|
|
2745
|
-
*
|
|
2746
|
-
*
|
|
2747
|
-
*
|
|
2748
|
-
*
|
|
2436
|
+
* The handle a page keeps on the runs it started, across a reload.
|
|
2437
|
+
*
|
|
2438
|
+
* `useWorkflowSubmit` is what makes a run survivable — the run id is that
|
|
2439
|
+
* hook's own state, so a refresh loses it while the run carries on — and the
|
|
2440
|
+
* `key` is what it looks the run up BY, because it is a lookup CAPABILITY:
|
|
2441
|
+
* there is no per-user filtering behind `find`, so the key IS the scoping
|
|
2442
|
+
* mechanism. Choosing one is easy to get wrong in three separate ways, and six
|
|
2443
|
+
* shipped templates had each written the same twenty lines to get it right.
|
|
2444
|
+
* This is those lines.
|
|
2445
|
+
*
|
|
2446
|
+
* **`useWorkflowSubmit` now mints one for itself** ({@link useDefaultRunKey}),
|
|
2447
|
+
* so a page resumes its own run across a reload with nothing written at the
|
|
2448
|
+
* call site — six of six page templates passed `useRunKey()` and
|
|
2449
|
+
* `recover: true`, which is a default in the wrong place. The hook stays
|
|
2450
|
+
* PUBLIC for the page that wants to choose: an app with accounts passes the
|
|
2451
|
+
* ACCOUNT's own id instead, and a run then follows the person to a new device,
|
|
2452
|
+
* which is a promise only a login can keep; a page whose run outlives the tab
|
|
2453
|
+
* passes `useRunKey({ storage: "local" })`.
|
|
2454
|
+
*
|
|
2455
|
+
* ## Three properties, and the rejected alternatives are why each one matters
|
|
2456
|
+
*
|
|
2457
|
+
* - **Opaque** — `crypto.randomUUID()`, never derived from what was submitted.
|
|
2458
|
+
* A key derived from the input collides the moment two people submit the same
|
|
2459
|
+
* thing, and they then recover each other's runs; it also carries what they
|
|
2460
|
+
* typed into a lookup token the platform deliberately stopped logging.
|
|
2461
|
+
* - **Short.** A `randomUUID` is 36 characters, well inside the 256 that
|
|
2462
|
+
* `POST /workflows/runs` allows a key.
|
|
2463
|
+
* - **Minted once per load and written back for the next one**, which is the
|
|
2464
|
+
* whole mechanism: the load that presses the button records the key with the
|
|
2465
|
+
* run, and the load after it finds the run by producing the same key.
|
|
2466
|
+
*
|
|
2467
|
+
* Storage rather than the page's own URL, for all of them. A `?key=` parameter
|
|
2468
|
+
* survives more (a new tab, a bookmark, a shared link) and that is the problem:
|
|
2469
|
+
* a URL is pasted into chats, copied into referrers and kept in history, and
|
|
2470
|
+
* what a leaked one buys is somebody else's work — reading it, and `cancel()` on
|
|
2471
|
+
* it. An app with accounts should pass the ACCOUNT's own id here instead, and
|
|
2472
|
+
* then a run follows the person to a new device, which is a promise only a login
|
|
2473
|
+
* can keep.
|
|
2474
|
+
*
|
|
2475
|
+
* ## The storage is the caller's decision, and it is not a detail
|
|
2476
|
+
*
|
|
2477
|
+
* `"session"` (the default) dies with the tab, which covers exactly the
|
|
2478
|
+
* interruption most pages have — a reload, a same-tab navigation, a crashed tab
|
|
2479
|
+
* — and is the same lifetime as this package's other two stores, the session
|
|
2480
|
+
* resume id (`session-resume-store.ts`) and the upload recall
|
|
2481
|
+
* (`_upload-recall.ts`), so both halves of a reload make the same promise.
|
|
2482
|
+
*
|
|
2483
|
+
* `"local"` is for a run that outlives all of that BY DESIGN — one that sleeps
|
|
2484
|
+
* between digests and may live a month, where closing the browser on Tuesday and
|
|
2485
|
+
* coming back on Friday to press Stop is the ordinary case rather than an edge
|
|
2486
|
+
* one, and a tab-scoped key would answer that with an empty form beside a run
|
|
2487
|
+
* still posting somewhere. It is as far as a key can go without a login, and no
|
|
2488
|
+
* further. `podcast-digest-workflow` is that template, and the reason this hook is still
|
|
2489
|
+
* called by name anywhere; the other five take the tab-scoped default the
|
|
2490
|
+
* submit hook mints for them.
|
|
2491
|
+
*
|
|
2492
|
+
* ## Anything ELSE a page stores back must be VALIDATED on read
|
|
2493
|
+
*
|
|
2494
|
+
* This key needs no validation, and it is worth saying why, because it is the
|
|
2495
|
+
* exception: any string is a legal key, so a value from storage can only fail to
|
|
2496
|
+
* match a run. A page that remembers something more — which MODE submitted, say
|
|
2497
|
+
* — is remembering a value it will turn into a name, and storage hands back a
|
|
2498
|
+
* string some earlier version of that page wrote: a renamed mode, a hand-edited
|
|
2499
|
+
* value, a slot another app on the origin happens to share. Unchecked, that
|
|
2500
|
+
* starts a run called `undefined` and answers a 400 nobody typed. Check it
|
|
2501
|
+
* against the page's own list on the way out (`recalledMode` in
|
|
2502
|
+
* `transcription-workflow/recover.ts` is the worked example) — the recall is the
|
|
2503
|
+
* page's, the validation is not optional.
|
|
2504
|
+
*
|
|
2505
|
+
* ## The slot is keyed by the page's own URL
|
|
2506
|
+
*
|
|
2507
|
+
* Every deployed agent is served from one origin at `/:slug/`, so a fixed name
|
|
2508
|
+
* would have two agents scaffolded from the same template recover each other's
|
|
2509
|
+
* runs. The key is the page's own directory — resolved through `"./"`, which
|
|
2510
|
+
* drops the query and the hash, since a reload carrying `?foo` or `#bar` has to
|
|
2511
|
+
* find the same key. Same call `session-resume-store.ts` makes, for the same
|
|
2512
|
+
* reason.
|
|
2513
|
+
*
|
|
2514
|
+
* One key per PAGE is right even for a page driving several workflows: `find` is
|
|
2515
|
+
* scoped by workflow as well as by key, so three hooks sharing one key recover
|
|
2516
|
+
* three separate runs. `transcription-workflow` is that page.
|
|
2517
|
+
*
|
|
2518
|
+
* Every access is guarded. Storage THROWS outright in some contexts (Safari
|
|
2519
|
+
* private mode, an iframe blocked by policy) and is ABSENT in others (any
|
|
2520
|
+
* server-side render), and a page that cannot remember its key must degrade to
|
|
2521
|
+
* the behaviour it would have had anyway — one run per load — rather than
|
|
2522
|
+
* failing to render.
|
|
2523
|
+
*/
|
|
2524
|
+
/** Where a run key lives, namespaced like this package's two other stores. */
|
|
2525
|
+
const PREFIX = "aai:run-key:";
|
|
2526
|
+
/** This page's own slot — see "The slot is keyed by the page's own URL". */
|
|
2527
|
+
function slotFor() {
|
|
2528
|
+
return urlSlot(PREFIX, "./");
|
|
2529
|
+
}
|
|
2530
|
+
/**
|
|
2531
|
+
* Read the key this page already has, or mint and remember one.
|
|
2532
|
+
*
|
|
2533
|
+
* Not exported: a page that wants a key wants it for the life of a component,
|
|
2534
|
+
* which is what the hook is. Calling this per render would mint a fresh key and
|
|
2535
|
+
* hand `recover` one nothing was ever started under.
|
|
2536
|
+
*/
|
|
2537
|
+
function mintRunKey(storage) {
|
|
2538
|
+
const slot = slotFor();
|
|
2539
|
+
const stored = storageGet(storage, slot);
|
|
2540
|
+
if (stored !== void 0) return stored;
|
|
2541
|
+
const minted = crypto.randomUUID();
|
|
2542
|
+
storageSet(storage, slot, minted);
|
|
2543
|
+
return minted;
|
|
2544
|
+
}
|
|
2545
|
+
/**
|
|
2546
|
+
* The key `useWorkflowSubmit` uses when the page named none.
|
|
2547
|
+
*
|
|
2548
|
+
* Two things it does that a plain `useRunKey()` at the call site cannot, and
|
|
2549
|
+
* both are about a page that DID name one:
|
|
2550
|
+
*
|
|
2551
|
+
* - **It mints nothing when the caller has a key.** Minting writes to storage,
|
|
2552
|
+
* so an unconditional `useRunKey()` inside the hook would leave a slot behind
|
|
2553
|
+
* on every page that passes an account id and never reads it back.
|
|
2554
|
+
* - **It stays reactive to the caller's key.** A key that arrives late — an
|
|
2555
|
+
* account id resolved after a login — must reach the lookup, which re-asks on
|
|
2556
|
+
* a changed key by design; freezing it into `useState` would pin the page to
|
|
2557
|
+
* whatever it held on its first render.
|
|
2558
|
+
*
|
|
2559
|
+
* The minted half is still frozen for the component's life, which is what
|
|
2560
|
+
* `useRunKey` freezes it for: a fresh key per render would record every run
|
|
2561
|
+
* under a name the next load cannot produce.
|
|
2562
|
+
*
|
|
2563
|
+
* @param explicit - The caller's own key, or undefined for a page with none.
|
|
2564
|
+
* @returns The key to record runs under and look them up by.
|
|
2565
|
+
*
|
|
2566
|
+
* @internal
|
|
2567
|
+
*/
|
|
2568
|
+
function useDefaultRunKey(explicit) {
|
|
2569
|
+
const [minted] = useState(() => explicit === void 0 ? mintRunKey("session") : "");
|
|
2570
|
+
return explicit ?? minted;
|
|
2571
|
+
}
|
|
2572
|
+
/**
|
|
2573
|
+
* A lookup key for `useWorkflowSubmit({ key })`, stable across reloads.
|
|
2574
|
+
*
|
|
2575
|
+
* @param options - See the module doc for the whole argument. The storage kind
|
|
2576
|
+
* is read once, when the key is minted: a value that changed afterwards would
|
|
2577
|
+
* be asking to move a key that has already been recorded with a run.
|
|
2578
|
+
* @returns The key to record runs under and to look them up by — the same one
|
|
2579
|
+
* for the life of the component, and for the next load in the same tab (or the
|
|
2580
|
+
* same browser, under `"local"`).
|
|
2581
|
+
*/
|
|
2582
|
+
function useRunKey(options = {}) {
|
|
2583
|
+
const { storage = "session" } = options;
|
|
2584
|
+
const [key] = useState(() => mintRunKey(storage));
|
|
2585
|
+
return key;
|
|
2586
|
+
}
|
|
2587
|
+
//#endregion
|
|
2588
|
+
//#region src/use-workflow-form.ts
|
|
2589
|
+
/**
|
|
2590
|
+
* The hook a FORM needs, as against the one a status view does.
|
|
2591
|
+
*
|
|
2592
|
+
* `useWorkflowRun` (`workflow-client.ts`) watches a run you already have.
|
|
2593
|
+
* This is what comes before it: `useWorkflowSubmit` starts a run and hands the
|
|
2594
|
+
* id straight to `useWorkflowRun`. Its sibling `useWorkflows` — the listing
|
|
2595
|
+
* `<WorkflowFields>` renders a form from — is `use-workflows.ts`.
|
|
2596
|
+
*
|
|
2597
|
+
* ## `useWorkflowSubmit` — a form's two halves in one hook
|
|
2749
2598
|
*
|
|
2750
2599
|
* A page that submits a workflow always needs the same four pieces of state:
|
|
2751
2600
|
* the run id, whether a submit is in flight, whether the RUN is still going, and
|
|
@@ -2870,6 +2719,458 @@ function useWorkflowSubmit(workflow, options = {}) {
|
|
|
2870
2719
|
};
|
|
2871
2720
|
}
|
|
2872
2721
|
//#endregion
|
|
2722
|
+
//#region src/_page-shell.tsx
|
|
2723
|
+
/**
|
|
2724
|
+
* The agent's own `name` and `greeting`, once the lookup lands.
|
|
2725
|
+
*
|
|
2726
|
+
* **Not skipped when the mount named the agent**, which is where this differs
|
|
2727
|
+
* from `mountClient()`'s `DefaultRoot`: there the response's only consumer is
|
|
2728
|
+
* the name fallback, so an explicit `name` made the request pure waste. Here the
|
|
2729
|
+
* GREETING is rendered too, and no argument to `mountPage()` supplies it.
|
|
2730
|
+
*
|
|
2731
|
+
* Every failure path already degrades to "the agent declared nothing" —
|
|
2732
|
+
* `fetchClientConfig` owns that — so there is nothing to report and nothing to
|
|
2733
|
+
* catch.
|
|
2734
|
+
*/
|
|
2735
|
+
function useAgentConfig() {
|
|
2736
|
+
const [config, setConfig] = useState();
|
|
2737
|
+
useEffect(() => {
|
|
2738
|
+
let cancelled = false;
|
|
2739
|
+
fetchClientConfig().then((answer) => {
|
|
2740
|
+
if (!cancelled) setConfig(answer);
|
|
2741
|
+
});
|
|
2742
|
+
return () => {
|
|
2743
|
+
cancelled = true;
|
|
2744
|
+
};
|
|
2745
|
+
}, []);
|
|
2746
|
+
return config;
|
|
2747
|
+
}
|
|
2748
|
+
/**
|
|
2749
|
+
* One workflow's form, its run, and what the run said.
|
|
2750
|
+
*
|
|
2751
|
+
* Its own component because `useWorkflowSubmit` is per WORKFLOW: the hook holds
|
|
2752
|
+
* the run id, so switching workflows has to start over rather than show one
|
|
2753
|
+
* workflow's run under another's form. The shell below mounts this with
|
|
2754
|
+
* `key={name}`, which is what makes that reset happen.
|
|
2755
|
+
*/
|
|
2756
|
+
function WorkflowRunner({ workflow }) {
|
|
2757
|
+
const { submitForm, run, pending, upload, pauseUpload, resumeUpload, error } = useWorkflowSubmit(workflow.name);
|
|
2758
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
2759
|
+
className: "flex flex-col gap-4",
|
|
2760
|
+
children: [
|
|
2761
|
+
workflow.description === void 0 ? null : /* @__PURE__ */ jsx("p", {
|
|
2762
|
+
className: "text-sm opacity-70",
|
|
2763
|
+
children: workflow.description
|
|
2764
|
+
}),
|
|
2765
|
+
/* @__PURE__ */ jsxs(Form, {
|
|
2766
|
+
onSubmit: (values) => submitForm(values),
|
|
2767
|
+
error,
|
|
2768
|
+
className: "flex flex-col gap-4",
|
|
2769
|
+
children: [/* @__PURE__ */ jsx(WorkflowFields, { workflow }), /* @__PURE__ */ jsx(SubmitButton, {
|
|
2770
|
+
pending,
|
|
2771
|
+
children: "Start"
|
|
2772
|
+
})]
|
|
2773
|
+
}),
|
|
2774
|
+
/* @__PURE__ */ jsx(UploadProgressBar, {
|
|
2775
|
+
upload,
|
|
2776
|
+
onPause: pauseUpload,
|
|
2777
|
+
onResume: resumeUpload
|
|
2778
|
+
}),
|
|
2779
|
+
/* @__PURE__ */ jsx(WorkflowProgress, { runId: run?.runId }),
|
|
2780
|
+
/* @__PURE__ */ jsx(WorkflowRunError, { run }),
|
|
2781
|
+
run?.status === "completed" && /* @__PURE__ */ jsx("pre", {
|
|
2782
|
+
className: "overflow-x-auto rounded-md border p-4 text-xs",
|
|
2783
|
+
children: JSON.stringify(run.output, null, 2)
|
|
2784
|
+
})
|
|
2785
|
+
]
|
|
2786
|
+
});
|
|
2787
|
+
}
|
|
2788
|
+
/**
|
|
2789
|
+
* The default page: the agent, its workflows, and one form.
|
|
2790
|
+
*
|
|
2791
|
+
* A picker appears only when there is more than one workflow — an agent with a
|
|
2792
|
+
* single workflow is the common case and a select with one option is chrome
|
|
2793
|
+
* about nothing.
|
|
2794
|
+
*/
|
|
2795
|
+
function DefaultPageShell({ name }) {
|
|
2796
|
+
const config = useAgentConfig();
|
|
2797
|
+
const { workflows, loading, error } = useWorkflows();
|
|
2798
|
+
const [picked, setPicked] = useState();
|
|
2799
|
+
const selected = workflows.find((entry) => entry.name === picked) ?? workflows[0];
|
|
2800
|
+
return /* @__PURE__ */ jsxs("main", {
|
|
2801
|
+
className: "mx-auto flex max-w-2xl flex-col gap-6 p-8",
|
|
2802
|
+
children: [
|
|
2803
|
+
/* @__PURE__ */ jsxs("header", {
|
|
2804
|
+
className: "flex flex-col gap-1",
|
|
2805
|
+
children: [/* @__PURE__ */ jsx("h1", {
|
|
2806
|
+
className: "text-2xl font-medium",
|
|
2807
|
+
children: name ?? config?.name ?? "Workflows"
|
|
2808
|
+
}), config?.greeting === void 0 ? null : /* @__PURE__ */ jsx("p", {
|
|
2809
|
+
className: "text-sm opacity-70",
|
|
2810
|
+
children: config.greeting
|
|
2811
|
+
})]
|
|
2812
|
+
}),
|
|
2813
|
+
error === void 0 ? null : /* @__PURE__ */ jsx("p", {
|
|
2814
|
+
role: "alert",
|
|
2815
|
+
className: "text-red-600",
|
|
2816
|
+
children: error
|
|
2817
|
+
}),
|
|
2818
|
+
loading && /* @__PURE__ */ jsx("p", {
|
|
2819
|
+
className: "text-sm opacity-70",
|
|
2820
|
+
children: "Loading workflows…"
|
|
2821
|
+
}),
|
|
2822
|
+
!loading && error === void 0 && workflows.length === 0 && /* @__PURE__ */ jsx("p", {
|
|
2823
|
+
className: "text-sm opacity-70",
|
|
2824
|
+
children: "This agent declares no workflows."
|
|
2825
|
+
}),
|
|
2826
|
+
workflows.length > 1 && /* @__PURE__ */ jsxs("label", {
|
|
2827
|
+
className: "flex flex-col gap-1 text-sm",
|
|
2828
|
+
children: ["Workflow", /* @__PURE__ */ jsx("select", {
|
|
2829
|
+
value: selected?.name ?? "",
|
|
2830
|
+
onChange: (event) => setPicked(event.target.value),
|
|
2831
|
+
className: "rounded-md border px-3 py-2",
|
|
2832
|
+
children: workflows.map((entry) => /* @__PURE__ */ jsx("option", {
|
|
2833
|
+
value: entry.name,
|
|
2834
|
+
children: entry.name
|
|
2835
|
+
}, entry.name))
|
|
2836
|
+
})]
|
|
2837
|
+
}),
|
|
2838
|
+
selected && /* @__PURE__ */ jsx(WorkflowRunner, { workflow: selected }, selected.name)
|
|
2839
|
+
]
|
|
2840
|
+
});
|
|
2841
|
+
}
|
|
2842
|
+
//#endregion
|
|
2843
|
+
//#region src/page.tsx
|
|
2844
|
+
/** @jsxImportSource react */
|
|
2845
|
+
/**
|
|
2846
|
+
* `mountPage()` — mount a WORKFLOW APP's UI: React, theme, no session.
|
|
2847
|
+
*
|
|
2848
|
+
* The twin of `mountClient()` for an agent whose front door is a form rather than a
|
|
2849
|
+
* microphone (`workflowApp()`). It is a separate entry rather than
|
|
2850
|
+
* an option on `mountClient()` because of what `mountClient()` unavoidably does: it
|
|
2851
|
+
* constructs a `BrowserSession`, which owns a WebSocket URL provider, an audio
|
|
2852
|
+
* graph, and a microphone request. A flag would have to make all of that
|
|
2853
|
+
* conditional, and every session hook would then have to answer "what does this
|
|
2854
|
+
* mean with no session?" — so the honest split is two mounts. A page that wants
|
|
2855
|
+
* voice uses `mountClient()`; a page that wants neither audio nor a socket uses this.
|
|
2856
|
+
*
|
|
2857
|
+
* Authoring is otherwise identical — the file is still `client.tsx`, still
|
|
2858
|
+
* React, still Tailwind, still the same theme tokens — so a workflow app reads
|
|
2859
|
+
* like every other agent. What it reaches for instead of `useSession()` is
|
|
2860
|
+
* `useWorkflowSubmit()` / `useWorkflowRun()`, neither of which needs a client
|
|
2861
|
+
* built for it.
|
|
2862
|
+
*
|
|
2863
|
+
* And `component` is OPTIONAL, the way `mountClient()`'s is: with none, this
|
|
2864
|
+
* mounts the generated shell in `_page-shell.tsx`. That is also what a workflow
|
|
2865
|
+
* app with NO `client.tsx` at all now gets — `default-client.tsx`, the prebuilt
|
|
2866
|
+
* page, branches on the agent's declared front door and mounts this.
|
|
2867
|
+
*/
|
|
2868
|
+
/**
|
|
2869
|
+
* Mount a page for an agent whose work happens in workflows.
|
|
2870
|
+
*
|
|
2871
|
+
* There is deliberately no session, no microphone, and no socket: the page talks
|
|
2872
|
+
* to the agent over the workflow HTTP API (`useWorkflowSubmit`/`useWorkflowRun`),
|
|
2873
|
+
* which is durable and outlives the tab.
|
|
2874
|
+
*
|
|
2875
|
+
* **Config only:** leave `component` out and the generated shell renders — a
|
|
2876
|
+
* form per declared workflow, the run's progress, its failure, its output.
|
|
2877
|
+
*
|
|
2878
|
+
* **A custom component:** pass `component` and it is rendered inside the theme
|
|
2879
|
+
* provider instead. The pieces the default shell is built from are all published
|
|
2880
|
+
* (`useWorkflows`, `<WorkflowFields>`, `useWorkflowSubmit`,
|
|
2881
|
+
* `<WorkflowProgress>`, `<WorkflowRunError>`), so replacing the shell does not
|
|
2882
|
+
* mean starting from `fetch`.
|
|
2883
|
+
*
|
|
2884
|
+
* @example The default shell
|
|
2885
|
+
* ```tsx
|
|
2886
|
+
* import { mountPage } from "@alexkroman1/aai-ui";
|
|
2887
|
+
*
|
|
2888
|
+
* mountPage({ name: "Digest" });
|
|
2889
|
+
* ```
|
|
2890
|
+
*
|
|
2891
|
+
* @example A custom component
|
|
2892
|
+
* ```tsx
|
|
2893
|
+
* import { mountPage, useWorkflows } from "@alexkroman1/aai-ui";
|
|
2894
|
+
*
|
|
2895
|
+
* function App() {
|
|
2896
|
+
* // No client to build and none to hoist: every workflow hook defaults to one
|
|
2897
|
+
* // aimed at the agent serving this page, built lazily and once.
|
|
2898
|
+
* const { workflows, loading } = useWorkflows();
|
|
2899
|
+
* if (loading) return <p>Loading…</p>;
|
|
2900
|
+
* return (
|
|
2901
|
+
* <ul>
|
|
2902
|
+
* {workflows.map((entry) => (
|
|
2903
|
+
* <li key={entry.name}>{entry.description ?? entry.name}</li>
|
|
2904
|
+
* ))}
|
|
2905
|
+
* </ul>
|
|
2906
|
+
* );
|
|
2907
|
+
* }
|
|
2908
|
+
*
|
|
2909
|
+
* mountPage({ name: "Digest", component: App });
|
|
2910
|
+
* ```
|
|
2911
|
+
*
|
|
2912
|
+
* @throws If the target element is not found in the DOM.
|
|
2913
|
+
*
|
|
2914
|
+
* @public
|
|
2915
|
+
*/
|
|
2916
|
+
function mountPage(config = {}) {
|
|
2917
|
+
const container = resolveContainer(config.target);
|
|
2918
|
+
setPageTitle(config.name);
|
|
2919
|
+
const Root = config.component;
|
|
2920
|
+
return mountRoot(container, createElement(ThemeProvider, { value: config.theme }, Root ? createElement(Root) : createElement(DefaultPageShell, { name: config.name })));
|
|
2921
|
+
}
|
|
2922
|
+
//#endregion
|
|
2923
|
+
//#region src/use-download-url.ts
|
|
2924
|
+
/**
|
|
2925
|
+
* `useDownloadUrl` — an upload id a run produced, as something `<audio>`,
|
|
2926
|
+
* `<img>` or `<a download>` will accept.
|
|
2927
|
+
*
|
|
2928
|
+
* `api.download(id)` resolves a `Blob`, and it has to: the byte route takes the
|
|
2929
|
+
* same bearer every workflow route does, and neither `<audio src>` nor
|
|
2930
|
+
* `<a href>` can send one. So every page that plays back what a run WROTE ends
|
|
2931
|
+
* up at the same four lines — `download` → `createObjectURL` → state — and the
|
|
2932
|
+
* two that are really the point are the two the four lines are wrapped in:
|
|
2933
|
+
*
|
|
2934
|
+
* - **`URL.revokeObjectURL` on cleanup.** An object URL pins its blob for the
|
|
2935
|
+
* life of the DOCUMENT. Miss it and every completed run's audio stays resident
|
|
2936
|
+
* until the tab closes, which on a page people run all day is a leak measured
|
|
2937
|
+
* in the size of the files.
|
|
2938
|
+
* - **A `cancelled` flag.** A second run settling while the first download is
|
|
2939
|
+
* still in flight otherwise sets state from the stale one, and the page plays
|
|
2940
|
+
* the previous run's audio under the current run's transcript — a wrong answer
|
|
2941
|
+
* that looks like a right one.
|
|
2942
|
+
*
|
|
2943
|
+
* Two templates had written this hook, identically, doc paragraph included, and
|
|
2944
|
+
* `aai-ui` exported no download helper at all. Both also faked `pending` by
|
|
2945
|
+
* checking `url === undefined && error === undefined`, which reads "idle" and
|
|
2946
|
+
* "downloading" as the same thing — so this reports it.
|
|
2947
|
+
*/
|
|
2948
|
+
/** No id: nothing pending, nothing to show. A shared object so `setState` no-ops. */
|
|
2949
|
+
const IDLE = { pending: false };
|
|
2950
|
+
/** Bytes in flight. Shared for the same reason as {@link IDLE}. */
|
|
2951
|
+
const PENDING = { pending: true };
|
|
2952
|
+
/**
|
|
2953
|
+
* Read an upload's bytes and hand back a URL a DOM element can use.
|
|
2954
|
+
*
|
|
2955
|
+
* @param uploadId - The id a completed run reported, or `undefined` before one
|
|
2956
|
+
* exists — which is what a page passes straight through while it waits, and
|
|
2957
|
+
* reports as idle rather than pending.
|
|
2958
|
+
* @param options - See {@link UseDownloadUrlOptions}.
|
|
2959
|
+
* @returns See {@link UseDownloadUrlResult}.
|
|
2960
|
+
*
|
|
2961
|
+
* @example
|
|
2962
|
+
* ```tsx no-check
|
|
2963
|
+
* import { useDownloadUrl, useWorkflowSubmit } from "@alexkroman1/aai-ui";
|
|
2964
|
+
* import type { spokenSummary } from "./agent.ts";
|
|
2965
|
+
*
|
|
2966
|
+
* function Playback() {
|
|
2967
|
+
* const { run } = useWorkflowSubmit<typeof spokenSummary>("spokenSummary");
|
|
2968
|
+
* const output = run?.status === "completed" ? run.output : undefined;
|
|
2969
|
+
* const audio = useDownloadUrl(output?.audio);
|
|
2970
|
+
* if (audio.pending) return <p>Fetching audio…</p>;
|
|
2971
|
+
* if (audio.error !== undefined) return <p role="alert">{audio.error}</p>;
|
|
2972
|
+
* return audio.url === undefined ? null : (
|
|
2973
|
+
* <a href={audio.url} download="summary.mp3">
|
|
2974
|
+
* Download
|
|
2975
|
+
* </a>
|
|
2976
|
+
* );
|
|
2977
|
+
* }
|
|
2978
|
+
* ```
|
|
2979
|
+
*
|
|
2980
|
+
* @public
|
|
2981
|
+
*/
|
|
2982
|
+
function useDownloadUrl(uploadId, options = {}) {
|
|
2983
|
+
const [state, setState] = useState(IDLE);
|
|
2984
|
+
const getClient = useWorkflowApiRef(options.api);
|
|
2985
|
+
useEffect(() => {
|
|
2986
|
+
if (uploadId === void 0) {
|
|
2987
|
+
setState(IDLE);
|
|
2988
|
+
return;
|
|
2989
|
+
}
|
|
2990
|
+
let cancelled = false;
|
|
2991
|
+
let objectUrl;
|
|
2992
|
+
setState(PENDING);
|
|
2993
|
+
getClient().download(uploadId).then((blob) => {
|
|
2994
|
+
if (cancelled) return;
|
|
2995
|
+
objectUrl = URL.createObjectURL(blob);
|
|
2996
|
+
setState({
|
|
2997
|
+
url: objectUrl,
|
|
2998
|
+
pending: false
|
|
2999
|
+
});
|
|
3000
|
+
}).catch((err) => {
|
|
3001
|
+
if (!cancelled) setState({
|
|
3002
|
+
error: errorMessage(err),
|
|
3003
|
+
pending: false
|
|
3004
|
+
});
|
|
3005
|
+
});
|
|
3006
|
+
return () => {
|
|
3007
|
+
cancelled = true;
|
|
3008
|
+
if (objectUrl !== void 0) URL.revokeObjectURL(objectUrl);
|
|
3009
|
+
};
|
|
3010
|
+
}, [uploadId, getClient]);
|
|
3011
|
+
return state;
|
|
3012
|
+
}
|
|
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
|
|
2873
3174
|
//#region src/use-workflow-runs.ts
|
|
2874
3175
|
/**
|
|
2875
3176
|
* The RUNS a workflow has had — the list a page shows beside its form.
|
|
@@ -3236,4 +3537,4 @@ function fileAt(input, field) {
|
|
|
3236
3537
|
return filesOf(input[field])[0];
|
|
3237
3538
|
}
|
|
3238
3539
|
//#endregion
|
|
3239
|
-
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 };
|