@alexkroman1/aai-ui 0.17.0 → 0.18.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 +6 -7
- package/dist/_submission-timing.d.ts +40 -0
- package/dist/_submission.d.ts +56 -0
- package/dist/_theme.d.ts +17 -0
- package/dist/_theme.test-d.d.ts +14 -0
- package/dist/{_utils-Mi-SCFA1.js → _utils-BlZBJPdj.js} +4 -5
- package/dist/_utils.d.ts +4 -5
- package/dist/agent-state.d.ts +4 -2
- package/dist/append-dictation.d.ts +23 -0
- package/dist/audio/dictation.d.ts +36 -0
- package/dist/audio/index.d.ts +4 -2
- package/dist/audio/index.js +44 -1
- package/dist/{chat-view-W6KcdjJY.js → chat-view-BwKpF0MN.js} +27 -17
- package/dist/{client-config-CzvWmw4X.js → client-config-DcMCWmkO.js} +1 -1
- package/dist/components/button.js +1 -1
- package/dist/components/chat-view.js +1 -2
- package/dist/components/controls.js +2 -2
- package/dist/components/form-fields.d.ts +24 -5
- package/dist/components/form-types.d.ts +32 -0
- package/dist/components/form.d.ts +1 -1
- package/dist/components/message-list.js +1 -2
- package/dist/components/session-state-dot.d.ts +30 -18
- package/dist/components/sidebar-layout.js +1 -1
- package/dist/components/start-screen.js +2 -2
- package/dist/components/tool-call-block.js +1 -2
- package/dist/components/tool-config-context.d.ts +21 -4
- package/dist/components/workflow-fields.d.ts +36 -12
- package/dist/components/workflow-pending-note.d.ts +30 -12
- package/dist/context-BF0KsI9X.js +473 -0
- package/dist/context.js +1 -381
- package/dist/default-client/assets/audio-CtBvSS3L.js +1 -0
- package/dist/default-client/assets/{capture-processor-Cx1AdMHL.js → capture-processor-BZXisGjZ.js} +1 -1
- package/dist/default-client/assets/index-BkjCv4MX.css +2 -0
- package/dist/default-client/assets/index-DUQoaxdq.js +127 -0
- package/dist/default-client/assets/{playback-processor-CCwTknSu.js → playback-processor-BHk-iXEU.js} +1 -1
- package/dist/default-client/index.html +2 -2
- package/dist/default-client.d.ts +1 -3
- package/dist/define-client.d.ts +30 -28
- package/dist/define-client.js +84 -43
- package/dist/dictation-transcriber.d.ts +49 -0
- package/dist/{eyebrow-UfmSz9yy.js → eyebrow-Bp4y1rZ9.js} +1 -1
- package/dist/{hooks-DN4gHYsj.js → hooks-1CoQY_Fc.js} +2 -2
- package/dist/hooks.js +1 -2
- package/dist/index.d.ts +11 -8
- package/dist/index.js +849 -263
- package/dist/internal.d.ts +1 -1
- package/dist/internal.js +6 -6
- package/dist/{message-list-BsaBkBgo.js → message-list-BBHLK05X.js} +3 -3
- package/dist/route-fetch.d.ts +7 -1
- package/dist/session/index.js +1 -1
- package/dist/{session-3lgPYNNO.js → session-Rud8mmU-.js} +2 -2
- package/dist/{tool-call-block-uXtMOUWN.js → tool-call-block-C8s6AhjS.js} +4 -4
- package/dist/tool-config-context-Bd-7xrfP.js +28 -0
- package/dist/types.d.ts +47 -2
- package/dist/{url-chips-oyy_Eiqh.js → url-chips-C_SHU6fq.js} +2 -2
- package/dist/use-dictation.d.ts +145 -0
- package/dist/use-route.d.ts +49 -7
- package/dist/use-run-key.d.ts +6 -1
- package/dist/{use-user-transcript-uyHhzy4d.js → use-user-transcript-lOu_uMnf.js} +1 -1
- package/dist/use-workflow-form.d.ts +74 -172
- package/dist/{use-workflow-run-DdhWmbx-.js → use-workflow-run-BTEemod0.js} +2 -2
- package/dist/use-workflow-runs.d.ts +8 -2
- package/dist/use-workflow-stream.d.ts +9 -5
- package/dist/workflow-def-types.d.ts +18 -2
- package/dist/workflow-field-kind.d.ts +59 -7
- package/dist/workflow-submission.d.ts +265 -0
- package/package.json +2 -2
- package/dist/default-client/assets/audio-Ct7JpSNT.js +0 -1
- package/dist/default-client/assets/index-BlUYCTB5.js +0 -126
- package/dist/default-client/assets/index-_-x5JLLa.css +0 -2
- package/dist/tool-config-context-Es4YUzV2.js +0 -19
package/dist/index.js
CHANGED
|
@@ -1,28 +1,58 @@
|
|
|
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 pageBaseUrl, r as setPageTitle, t as isTypingTarget } from "./_utils-
|
|
3
|
-
import { n as fetchClientConfig, t as buildAgentUrl } from "./client-config-
|
|
4
|
-
import { a as ticketCarriage, c as resolveReported, d as storageSet, f as urlSlot, i as resolveSessionTokenSync, l as storageGet, r as resolveSessionToken, s as browserClientId, t as createBrowserSession, u as storageRemove } from "./session-
|
|
5
|
-
import {
|
|
6
|
-
import { a as useAgentState, i as selectAgentState, n as useToolCallStart, r as useToolResult, t as useEvent } from "./hooks-
|
|
7
|
-
import { a as useConversation, i as interleave, n as Markdown, o as AutoScroll, r as ConversationView, t as MessageList } from "./message-list-
|
|
1
|
+
import { a as AGENT_STATE_LABELS, i as SessionErrorBanner, n as ConsoleShell, r as SessionStateDot, t as ChatView } from "./chat-view-BwKpF0MN.js";
|
|
2
|
+
import { n as pageBaseUrl, r as setPageTitle, t as isTypingTarget } from "./_utils-BlZBJPdj.js";
|
|
3
|
+
import { n as fetchClientConfig, t as buildAgentUrl } from "./client-config-DcMCWmkO.js";
|
|
4
|
+
import { a as ticketCarriage, c as resolveReported, d as storageSet, f as urlSlot, i as resolveSessionTokenSync, l as storageGet, r as resolveSessionToken, s as browserClientId, t as createBrowserSession, u as storageRemove } from "./session-Rud8mmU-.js";
|
|
5
|
+
import { a as useSessionActions, c as useSessionSelector, i as useSession, l as useSessionStatus, n as ThemeProvider, o as useSessionCore, r as useOptionalSessionCore, s as useSessionError, u as useTheme } from "./context-BF0KsI9X.js";
|
|
6
|
+
import { a as useAgentState, i as selectAgentState, n as useToolCallStart, r as useToolResult, t as useEvent } from "./hooks-1CoQY_Fc.js";
|
|
7
|
+
import { a as useConversation, i as interleave, n as Markdown, o as AutoScroll, r as ConversationView, t as MessageList } from "./message-list-BBHLK05X.js";
|
|
8
8
|
import { a as inkTint, i as focusRingStyle, n as FOCUS_RING } from "./_colors-CpZO-88A.js";
|
|
9
9
|
import { Button } from "./components/button.js";
|
|
10
|
-
import { a as useFlash, i as useCopy } from "./url-chips-
|
|
10
|
+
import { a as useFlash, i as useCopy } from "./url-chips-C_SHU6fq.js";
|
|
11
11
|
import { Controls } from "./components/controls.js";
|
|
12
|
-
import { n as useUserTranscript } from "./use-user-transcript-
|
|
13
|
-
import { n as ToolCallRow } from "./tool-call-block-
|
|
12
|
+
import { n as useUserTranscript } from "./use-user-transcript-lOu_uMnf.js";
|
|
13
|
+
import { n as ToolCallRow } from "./tool-call-block-C8s6AhjS.js";
|
|
14
14
|
import { SidebarLayout } from "./components/sidebar-layout.js";
|
|
15
15
|
import { StartScreen } from "./components/start-screen.js";
|
|
16
|
-
import { a as useWorkflowProgress, c as isTerminal, o as
|
|
16
|
+
import { a as useWorkflowProgress, c as createWorkflowApi, l as isTerminal, o as repeatUntil, r as useWorkflowRun, s as useWorkflowApiRef } from "./use-workflow-run-BTEemod0.js";
|
|
17
17
|
import { mountClient, mountRoot, resolveContainer } from "./define-client.js";
|
|
18
18
|
import { errorMessage, errorMessage as errorMessage$1 } from "@alexkroman1/aai";
|
|
19
19
|
import { InboxServerFrameSchema } from "@alexkroman1/aai/protocol";
|
|
20
20
|
import { CLIENT_ID_RE, createBackoffLoop, createEpoch } from "@alexkroman1/aai/internal";
|
|
21
21
|
import { Fragment, createContext, createElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
22
|
+
import { formatBytes, isRecord, omitUndefined, safeJsonParse as safeJsonParse$1 } from "@alexkroman1/aai/utils";
|
|
22
23
|
import clsx from "clsx";
|
|
23
24
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
24
|
-
import { formatBytes, isRecord, omitUndefined, safeJsonParse as safeJsonParse$1 } from "@alexkroman1/aai/utils";
|
|
25
25
|
import { assign, createActor, fromPromise, raise, setup, stateIn } from "xstate";
|
|
26
|
+
import { DICTATION_MAX_SECONDS, DICTATION_SAMPLE_RATE } from "@alexkroman1/aai/experimental";
|
|
27
|
+
//#region src/append-dictation.ts
|
|
28
|
+
/**
|
|
29
|
+
* `appendDictation(draft, text)` — where dictated words land in a text box
|
|
30
|
+
* that may already hold some.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* `draft` with `text` added after it, joined by exactly one space: a draft
|
|
34
|
+
* ending in whitespace or a newline does not grow a double space, and an empty
|
|
35
|
+
* or blank draft is replaced rather than given a leading one.
|
|
36
|
+
*
|
|
37
|
+
* Never REPLACES what was typed — the words someone said are an addition to
|
|
38
|
+
* the box, which is what makes speaking into a half-written prompt safe.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* import { appendDictation } from "@alexkroman1/aai-ui";
|
|
43
|
+
*
|
|
44
|
+
* appendDictation("Build me an agent", "that books tables."); // "Build me an agent that books tables."
|
|
45
|
+
* appendDictation("", "Hello"); // "Hello"
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* @public
|
|
49
|
+
*/
|
|
50
|
+
function appendDictation(draft, text) {
|
|
51
|
+
const words = text.trim();
|
|
52
|
+
if (!words) return draft;
|
|
53
|
+
return draft.trim() ? `${draft.trimEnd()} ${words}` : words;
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
26
56
|
//#region src/client-tool.ts
|
|
27
57
|
/**
|
|
28
58
|
* The page's half of a `clientTool`: run the handler when the agent calls the
|
|
@@ -399,10 +429,27 @@ function useDeclareFieldsPending(pending) {
|
|
|
399
429
|
async function collectValues(form) {
|
|
400
430
|
const values = {};
|
|
401
431
|
for (const element of Array.from(form.elements)) if (element instanceof HTMLInputElement) await readInput(element, values);
|
|
402
|
-
else if ((element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) && element.name !== "" && !element.disabled) values[element.name] = element instanceof HTMLSelectElement ? readSelect(element) : element
|
|
432
|
+
else if ((element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) && element.name !== "" && !element.disabled) values[element.name] = element instanceof HTMLSelectElement ? readSelect(element) : readTextArea(element);
|
|
403
433
|
return values;
|
|
404
434
|
}
|
|
405
435
|
/**
|
|
436
|
+
* One `<textarea>`: its string, or with `split="lines"` the non-blank lines
|
|
437
|
+
* trimmed — "one point per line", for a schema's `string[]`. Blank lines go, so
|
|
438
|
+
* a trailing newline is not a requirement to cover `""`.
|
|
439
|
+
*/
|
|
440
|
+
function readTextArea(textarea) {
|
|
441
|
+
if (splitMode(textarea.dataset.aaiSplit) !== "lines") return textarea.value;
|
|
442
|
+
return textarea.value.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
443
|
+
}
|
|
444
|
+
/** The `data-aai-split` attribute as a {@link TextSplitMode}, defaulting to `"none"`. */
|
|
445
|
+
function splitMode(raw) {
|
|
446
|
+
return raw === "lines" ? raw : "none";
|
|
447
|
+
}
|
|
448
|
+
/** The `data-aai-as` attribute as a {@link FileValueShape}, defaulting to `"file"`. */
|
|
449
|
+
function valueShape(raw) {
|
|
450
|
+
return raw === "namedText" ? raw : "file";
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
406
453
|
* One `<select>`, by arity.
|
|
407
454
|
*
|
|
408
455
|
* `HTMLSelectElement.value` is the FIRST selected option and nothing more, so a
|
|
@@ -411,10 +458,22 @@ async function collectValues(form) {
|
|
|
411
458
|
* for a list. `selectedOptions` is the whole of what the user picked; nothing
|
|
412
459
|
* selected contributes `[]`, which is the honest answer for a control that is
|
|
413
460
|
* present and empty rather than one that was left blank.
|
|
461
|
+
*
|
|
462
|
+
* `data-aai-value="number"` (set by `<WorkflowFields>` for a numeric choice
|
|
463
|
+
* set) reads each chosen option back as a NUMBER: an `<option>` can only hold a
|
|
464
|
+
* string, and `"1"` against `z.literal([1, 2])` is a refused submission. An
|
|
465
|
+
* option that is not numeric stays a string, as a number input's does.
|
|
414
466
|
*/
|
|
415
467
|
function readSelect(select) {
|
|
416
|
-
|
|
417
|
-
|
|
468
|
+
const read = select.dataset.aaiValue === "number" ? asNumber : (value) => value;
|
|
469
|
+
if (!select.multiple) return read(select.value);
|
|
470
|
+
return Array.from(select.selectedOptions, (option) => read(option.value));
|
|
471
|
+
}
|
|
472
|
+
/** The option's value as a number where it is one — `""` and prose stay strings. */
|
|
473
|
+
function asNumber(value) {
|
|
474
|
+
if (value.trim() === "") return value;
|
|
475
|
+
const parsed = Number(value);
|
|
476
|
+
return Number.isFinite(parsed) ? parsed : value;
|
|
418
477
|
}
|
|
419
478
|
/** One `<input>`, by type. */
|
|
420
479
|
async function readInput(input, values) {
|
|
@@ -436,7 +495,7 @@ async function readInput(input, values) {
|
|
|
436
495
|
case "file": {
|
|
437
496
|
const files = Array.from(input.files ?? []);
|
|
438
497
|
if (files.length === 0) return;
|
|
439
|
-
const chosen = await readFiles(files, readMode(input.dataset.aaiRead));
|
|
498
|
+
const chosen = await readFiles(files, readMode(input.dataset.aaiRead), valueShape(input.dataset.aaiAs));
|
|
440
499
|
values[input.name] = input.multiple ? chosen : chosen[0];
|
|
441
500
|
return;
|
|
442
501
|
}
|
|
@@ -451,8 +510,12 @@ async function readInput(input, values) {
|
|
|
451
510
|
* the bytes is the upload request `useWorkflowSubmit` makes — which streams the
|
|
452
511
|
* same `File` object straight to the agent.
|
|
453
512
|
*/
|
|
454
|
-
function readFiles(files, read) {
|
|
513
|
+
function readFiles(files, read, shape) {
|
|
455
514
|
if (read === "upload") return Promise.resolve([...files]);
|
|
515
|
+
if (shape === "namedText") return Promise.all(files.map(async (file) => ({
|
|
516
|
+
name: file.name,
|
|
517
|
+
text: await file.text()
|
|
518
|
+
})));
|
|
456
519
|
return Promise.all(files.map((file) => describeFile(file, read)));
|
|
457
520
|
}
|
|
458
521
|
/** The `data-aai-read` attribute as a {@link FileReadMode}, defaulting to `"none"`. */
|
|
@@ -657,11 +720,18 @@ function NumberField(props) {
|
|
|
657
720
|
* Accepts every `<textarea>` attribute except `name` and `className`, plus the
|
|
658
721
|
* shared {@link FieldShell} props. `rows` defaults to 4.
|
|
659
722
|
*
|
|
660
|
-
*
|
|
723
|
+
* **`split="lines"` contributes a `string[]`** — one entry per non-blank line,
|
|
724
|
+
* trimmed — which is the control for a schema's `z.array(z.string())`: "one
|
|
725
|
+
* point per line". `document-redline-workflow` reshaped the textarea's string
|
|
726
|
+
* by hand on submit; the field does it now, so `<WorkflowFields>` can render
|
|
727
|
+
* it for a `string[]` property and a hand-written one can match. See
|
|
728
|
+
* {@link TextSplitMode}.
|
|
729
|
+
*
|
|
730
|
+
* @param props - {@link FieldShell} props, `split`, and `<textarea>` attributes.
|
|
661
731
|
*
|
|
662
732
|
* @public
|
|
663
733
|
*/
|
|
664
|
-
function TextAreaField({ name, label, hint, className, rows = 4, ...rest }) {
|
|
734
|
+
function TextAreaField({ name, label, hint, className, rows = 4, split = "none", ...rest }) {
|
|
665
735
|
const id = useId();
|
|
666
736
|
const control = useControlProps();
|
|
667
737
|
return /* @__PURE__ */ jsx(Field, {
|
|
@@ -673,6 +743,7 @@ function TextAreaField({ name, label, hint, className, rows = 4, ...rest }) {
|
|
|
673
743
|
id,
|
|
674
744
|
name,
|
|
675
745
|
rows,
|
|
746
|
+
"data-aai-split": split,
|
|
676
747
|
...control,
|
|
677
748
|
...rest
|
|
678
749
|
})
|
|
@@ -750,6 +821,10 @@ function CheckboxField({ name, label, hint, className, ...rest }) {
|
|
|
750
821
|
})
|
|
751
822
|
});
|
|
752
823
|
}
|
|
824
|
+
/** The read a non-upload field makes: `namedText` is a reading of the text, so it asks for it. */
|
|
825
|
+
function readModeFor(read, as) {
|
|
826
|
+
return as === "namedText" && read === "none" ? "text" : read;
|
|
827
|
+
}
|
|
753
828
|
/**
|
|
754
829
|
* A file picker. Contributes a {@link FileValue} (or an array, with `multiple`)
|
|
755
830
|
* to {@link FormValues} — or nothing when no file was chosen.
|
|
@@ -769,18 +844,26 @@ function CheckboxField({ name, label, hint, className, ...rest }) {
|
|
|
769
844
|
* {@link FileReadMode} for the four values; `upload` is shorthand for
|
|
770
845
|
* `read="upload"`.
|
|
771
846
|
*
|
|
847
|
+
* **`as="namedText"` contributes `{ name, text }`** ({@link NamedTextValue})
|
|
848
|
+
* instead of the metadata-bearing {@link FileValue}, and implies
|
|
849
|
+
* `read="text"`: the shape a schema declares for a draft to redline or a
|
|
850
|
+
* config to apply, and the one `<WorkflowFields>` renders for an object
|
|
851
|
+
* property of exactly that shape. `document-redline-workflow` reshaped the
|
|
852
|
+
* `FileValue` by hand on submit; the field does it now.
|
|
853
|
+
*
|
|
772
854
|
* Otherwise accepts every `<input>` attribute except `name`, `className` and
|
|
773
855
|
* `type`, plus the shared {@link FieldShell} props — so `accept` and
|
|
774
856
|
* `multiple` are passed straight through.
|
|
775
857
|
*
|
|
776
|
-
* @param props - {@link FieldShell} props, `read`/`upload`, and `<input>`
|
|
858
|
+
* @param props - {@link FieldShell} props, `read`/`upload`/`as`, and `<input>`
|
|
777
859
|
* attributes.
|
|
778
860
|
*
|
|
779
861
|
* @public
|
|
780
862
|
*/
|
|
781
|
-
function FileField({ name, label, hint, className, read = "none", upload = false, ...rest }) {
|
|
863
|
+
function FileField({ name, label, hint, className, read = "none", upload = false, as = "file", ...rest }) {
|
|
782
864
|
const id = useId();
|
|
783
865
|
const control = useFileControlProps();
|
|
866
|
+
const mode = upload ? "upload" : readModeFor(read, as);
|
|
784
867
|
return /* @__PURE__ */ jsx(Field, {
|
|
785
868
|
label,
|
|
786
869
|
hint,
|
|
@@ -790,7 +873,8 @@ function FileField({ name, label, hint, className, read = "none", upload = false
|
|
|
790
873
|
id,
|
|
791
874
|
name,
|
|
792
875
|
type: "file",
|
|
793
|
-
"data-aai-read":
|
|
876
|
+
"data-aai-read": mode,
|
|
877
|
+
"data-aai-as": as,
|
|
794
878
|
...control,
|
|
795
879
|
...rest
|
|
796
880
|
})
|
|
@@ -1366,16 +1450,82 @@ function useWorkflows(options = {}) {
|
|
|
1366
1450
|
* way it treats `"none"`.
|
|
1367
1451
|
*/
|
|
1368
1452
|
/**
|
|
1453
|
+
* Whether `schema` is an object of exactly `{ name: string, text: string }`,
|
|
1454
|
+
* both required — the one object shape with a declared control.
|
|
1455
|
+
*/
|
|
1456
|
+
function isNamedTextSchema(schema) {
|
|
1457
|
+
if (schemaTypeOf(schema) !== "object" || !isRecord(schema.properties)) return false;
|
|
1458
|
+
const keys = Object.keys(schema.properties);
|
|
1459
|
+
if (keys.length !== 2 || !keys.includes("name") || !keys.includes("text")) return false;
|
|
1460
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
1461
|
+
return required.includes("name") && required.includes("text") && schemaTypeOf(schema.properties.name) === "string" && schemaTypeOf(schema.properties.text) === "string";
|
|
1462
|
+
}
|
|
1463
|
+
/** Whether `schema` is an array of strings. */
|
|
1464
|
+
function isStringListSchema(schema) {
|
|
1465
|
+
return schemaTypeOf(schema) === "array" && schemaTypeOf(schema.items) === "string";
|
|
1466
|
+
}
|
|
1467
|
+
/** The members of a schema's `anyOf`/`oneOf`, when it is a union at all. */
|
|
1468
|
+
function unionMembersOf(schema) {
|
|
1469
|
+
const members = schema.anyOf ?? schema.oneOf;
|
|
1470
|
+
return Array.isArray(members) ? members : void 0;
|
|
1471
|
+
}
|
|
1472
|
+
/** `{ type: "null" }` — the half of a nullable union that wants no control. */
|
|
1473
|
+
function isNullSchema(member) {
|
|
1474
|
+
return isRecord(member) && member.type === "null";
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* The schema with a NULLABLE union unwrapped: `anyOf: [X, { type: "null" }]`
|
|
1478
|
+
* reads as `X`, the way `type: ["string", "null"]` reads as `"string"`.
|
|
1479
|
+
*
|
|
1480
|
+
* Zod 4 converts `z.enum([…]).nullable()` (and any nullable whose inner schema
|
|
1481
|
+
* carries keywords of its own) to that `anyOf`, where a bare
|
|
1482
|
+
* `z.string().nullable()` becomes the type list — so a required nullable enum
|
|
1483
|
+
* used to reach the type switch with no `type` at all, render NO control, and
|
|
1484
|
+
* leave a required property out of every submission. A union with two or more
|
|
1485
|
+
* non-null members is some other shape and is answered as itself; a schema
|
|
1486
|
+
* that is not an object has no answer.
|
|
1487
|
+
*
|
|
1488
|
+
* Not exported from the package barrel, like {@link schemaTypeOf}.
|
|
1489
|
+
*/
|
|
1490
|
+
function nonNullSchemaOf(schema) {
|
|
1491
|
+
if (!isRecord(schema)) return void 0;
|
|
1492
|
+
const members = unionMembersOf(schema);
|
|
1493
|
+
if (members === void 0) return schema;
|
|
1494
|
+
const rest = members.filter((member) => !isNullSchema(member));
|
|
1495
|
+
return rest.length === 1 ? nonNullSchemaOf(rest[0]) : schema;
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* The choices a select renders for `schema`, or undefined when it is not a
|
|
1499
|
+
* closed set: a non-empty `enum`, or the `const` of each member of a union of
|
|
1500
|
+
* literals — which is how zod 4 converts `z.union([z.literal("p"),
|
|
1501
|
+
* z.literal("q")])`, where `z.enum` and `z.literal([1, 2])` convert to `enum`.
|
|
1502
|
+
* The values keep their own type (`[1, 2]` stays numeric), so the control can
|
|
1503
|
+
* submit what the schema declared.
|
|
1504
|
+
*
|
|
1505
|
+
* Not exported from the package barrel, like {@link schemaTypeOf}.
|
|
1506
|
+
*/
|
|
1507
|
+
function schemaChoicesOf(schema) {
|
|
1508
|
+
const inner = nonNullSchemaOf(schema);
|
|
1509
|
+
if (inner === void 0) return void 0;
|
|
1510
|
+
if (Array.isArray(inner.enum)) return inner.enum.length > 0 ? inner.enum : void 0;
|
|
1511
|
+
const members = unionMembersOf(inner)?.filter((member) => !isNullSchema(member));
|
|
1512
|
+
if (members === void 0 || members.length === 0) return void 0;
|
|
1513
|
+
const consts = members.map((member) => isRecord(member) ? member.const : void 0);
|
|
1514
|
+
return consts.every((value) => value !== void 0) ? consts : void 0;
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1369
1517
|
* A property's declared type, taking the first non-null member of a union.
|
|
1370
1518
|
*
|
|
1371
1519
|
* `["string", "null"]` is how an optional-and-nullable field converts; the
|
|
1372
|
-
* control is the one the non-null half wants.
|
|
1373
|
-
*
|
|
1520
|
+
* control is the one the non-null half wants. A nullable `anyOf` is read the
|
|
1521
|
+
* same way ({@link nonNullSchemaOf}). Not exported from the package barrel —
|
|
1522
|
+
* the published question is the KIND, and this is the one detail
|
|
1374
1523
|
* `<WorkflowFields>` needs past it (an integer's `step`).
|
|
1375
1524
|
*/
|
|
1376
1525
|
function schemaTypeOf(schema) {
|
|
1377
|
-
|
|
1378
|
-
|
|
1526
|
+
const inner = nonNullSchemaOf(schema);
|
|
1527
|
+
if (inner === void 0) return void 0;
|
|
1528
|
+
const { type } = inner;
|
|
1379
1529
|
if (typeof type === "string") return type;
|
|
1380
1530
|
return Array.isArray(type) ? type.find((member) => member !== "null") : void 0;
|
|
1381
1531
|
}
|
|
@@ -1390,10 +1540,18 @@ function schemaTypeOf(schema) {
|
|
|
1390
1540
|
* person to type an id no person has. The declaration is the workflow's
|
|
1391
1541
|
* (`workflow({ uploads: [...] })`), not the schema's, precisely because a
|
|
1392
1542
|
* marker inside the schema would only work for the library that carried it.
|
|
1393
|
-
* 2. **Then a
|
|
1394
|
-
*
|
|
1543
|
+
* 2. **Then a closed set of choices**, before the type switch: a non-empty
|
|
1544
|
+
* `enum`, or a union of `const` literals (`schemaChoicesOf`) — an enum
|
|
1545
|
+
* of strings is a `string` too, and a select is the narrower, better
|
|
1546
|
+
* control. A nullable union (`anyOf: [X, { type: "null" }]`) is read as `X`
|
|
1547
|
+
* throughout, as `type: [X, "null"]` already was.
|
|
1395
1548
|
* 3. **Then the type** — `boolean`, `number`/`integer`, `string`.
|
|
1396
|
-
* 4. **
|
|
1549
|
+
* 4. **Then the two non-scalar shapes with a control** — an array of strings
|
|
1550
|
+
* is `"lines"`, an object of exactly `{ name: string, text: string }` is
|
|
1551
|
+
* `"namedText"`. A page may also name either through `render`, which beats
|
|
1552
|
+
* the schema for that property (it is the author saying what the shape
|
|
1553
|
+
* means) but never a declared upload.
|
|
1554
|
+
* 5. **Anything else is `"none"`** rather than a guess. See
|
|
1397
1555
|
* {@link WorkflowFieldKind}.
|
|
1398
1556
|
*
|
|
1399
1557
|
* @example
|
|
@@ -1416,20 +1574,24 @@ function schemaTypeOf(schema) {
|
|
|
1416
1574
|
* @param schema - The property's JSON Schema, as `GET workflows` reports it.
|
|
1417
1575
|
* Anything that is not an object reads as `"none"`.
|
|
1418
1576
|
* @param options - `upload` says the property is named in the workflow's own
|
|
1419
|
-
* `uploads` declaration, which is what step 1 above tests
|
|
1577
|
+
* `uploads` declaration, which is what step 1 above tests; `render` is the
|
|
1578
|
+
* page's own choice for the property (`<WorkflowFields render>`).
|
|
1420
1579
|
*
|
|
1421
1580
|
* @public
|
|
1422
1581
|
*/
|
|
1423
1582
|
function fieldKindFor(schema, options) {
|
|
1424
1583
|
if (options?.upload === true) return "file";
|
|
1425
|
-
if (
|
|
1426
|
-
const
|
|
1427
|
-
if (
|
|
1428
|
-
|
|
1584
|
+
if (options?.render !== void 0) return options.render;
|
|
1585
|
+
const inner = nonNullSchemaOf(schema);
|
|
1586
|
+
if (inner === void 0) return "none";
|
|
1587
|
+
if (schemaChoicesOf(inner) !== void 0) return "select";
|
|
1588
|
+
switch (schemaTypeOf(inner)) {
|
|
1429
1589
|
case "boolean": return "checkbox";
|
|
1430
1590
|
case "number":
|
|
1431
1591
|
case "integer": return "number";
|
|
1432
1592
|
case "string": return "text";
|
|
1593
|
+
case "array": return isStringListSchema(inner) ? "lines" : "none";
|
|
1594
|
+
case "object": return isNamedTextSchema(inner) ? "namedText" : "none";
|
|
1433
1595
|
default: return "none";
|
|
1434
1596
|
}
|
|
1435
1597
|
}
|
|
@@ -1445,18 +1607,28 @@ function fieldKindFor(schema, options) {
|
|
|
1445
1607
|
* workflow's form matches its schema by construction, so adding a field to the
|
|
1446
1608
|
* schema adds it to the page and nothing can drift.
|
|
1447
1609
|
*
|
|
1448
|
-
* ## It covers SCALARS, and says so rather than guessing
|
|
1610
|
+
* ## It covers SCALARS and two more shapes, and says so rather than guessing
|
|
1449
1611
|
*
|
|
1450
|
-
* A string, number, integer, boolean or enum has one obvious control each.
|
|
1451
|
-
* nested
|
|
1612
|
+
* A string, number, integer, boolean or enum has one obvious control each. Most
|
|
1613
|
+
* nested objects and arrays do not — every choice (a JSON textarea, a repeater,
|
|
1452
1614
|
* a comma-separated string) is a guess about what the author meant, and a guess
|
|
1453
1615
|
* that produces a value the schema then rejects is worse than no field at all.
|
|
1454
1616
|
* So those are SKIPPED, and the fields for them are written by hand: every field
|
|
1455
1617
|
* in this package is a plain named control, so a hand-written one composes with
|
|
1456
1618
|
* a generated one inside the same {@link Form}.
|
|
1619
|
+
*
|
|
1620
|
+
* Two non-scalar shapes DO have an honest control, and are rendered: an array
|
|
1621
|
+
* of strings as `<TextAreaField split="lines">` (one per line), and an object
|
|
1622
|
+
* of exactly `{ name: string, text: string }` as `<FileField read="text"
|
|
1623
|
+
* as="namedText">` (a draft, read in the browser). A page may also ask for
|
|
1624
|
+
* either by property name through `render`, for a schema that is one of those
|
|
1625
|
+
* but does not say so plainly. Both were the hand-written half of
|
|
1626
|
+
* `document-redline-workflow`'s mixed form, reshaped on submit; the fields
|
|
1627
|
+
* contribute the schema's shape now, so the form is all declared.
|
|
1457
1628
|
*/
|
|
1458
1629
|
/**
|
|
1459
|
-
* Render one field per
|
|
1630
|
+
* Render one field per property of a workflow's input schema that has an
|
|
1631
|
+
* honest control — every scalar, an array of strings, a `{ name, text }` object.
|
|
1460
1632
|
*
|
|
1461
1633
|
* Pass the workflow's NAME and the schema is fetched here; pass a
|
|
1462
1634
|
* {@link WorkflowSummary} you already hold and nothing is fetched. The name form
|
|
@@ -1487,31 +1659,45 @@ function fieldKindFor(schema, options) {
|
|
|
1487
1659
|
* }
|
|
1488
1660
|
* ```
|
|
1489
1661
|
*
|
|
1490
|
-
* @param props -
|
|
1662
|
+
* @param props - See {@link WorkflowFieldsProps}.
|
|
1491
1663
|
*
|
|
1492
1664
|
* @public
|
|
1493
1665
|
*/
|
|
1494
|
-
function WorkflowFields({ workflow }) {
|
|
1495
|
-
const { workflows, loading } = useWorkflows(typeof workflow === "string" ? {} : { skip: true });
|
|
1666
|
+
function WorkflowFields({ workflow, render }) {
|
|
1667
|
+
const { workflows, loading, error } = useWorkflows(typeof workflow === "string" ? {} : { skip: true });
|
|
1496
1668
|
const summary = typeof workflow === "string" ? workflows.find((entry) => entry.name === workflow) : workflow;
|
|
1497
1669
|
useDeclareFieldsPending(loading);
|
|
1670
|
+
const unknownName = typeof workflow === "string" && !loading && error === void 0 && summary === void 0;
|
|
1671
|
+
const declared = workflows.map((entry) => entry.name).join(", ");
|
|
1672
|
+
useEffect(() => {
|
|
1673
|
+
if (!unknownName) return;
|
|
1674
|
+
console.warn(`[aai-ui] <WorkflowFields workflow=${JSON.stringify(workflow)}> matches no workflow the agent declares (${declared === "" ? "none listed" : declared}); it renders no fields. Check the name against agent({ workflows }).`);
|
|
1675
|
+
}, [
|
|
1676
|
+
unknownName,
|
|
1677
|
+
workflow,
|
|
1678
|
+
declared
|
|
1679
|
+
]);
|
|
1498
1680
|
const schema = asObjectSchema(summary?.inputSchema);
|
|
1499
1681
|
if (!schema?.properties) return null;
|
|
1500
1682
|
const required = new Set(schema.required ?? []);
|
|
1501
1683
|
const uploads = new Set(summary?.uploads ?? []);
|
|
1502
|
-
return /* @__PURE__ */ jsx(Fragment$1, { children: Object.entries(schema.properties).map(([name, property]) => /* @__PURE__ */ jsx(SchemaField, {
|
|
1684
|
+
return /* @__PURE__ */ jsx(Fragment$1, { children: Object.entries(schema.properties).map(([name, property]) => isRecord(property) ? /* @__PURE__ */ jsx(SchemaField, {
|
|
1503
1685
|
name,
|
|
1504
1686
|
property,
|
|
1505
1687
|
required: required.has(name),
|
|
1506
|
-
upload: uploads.has(name)
|
|
1507
|
-
|
|
1688
|
+
upload: uploads.has(name),
|
|
1689
|
+
render: render?.[name]
|
|
1690
|
+
}, name) : null) });
|
|
1508
1691
|
}
|
|
1509
1692
|
/** One property's control, or nothing when its type has no obvious one. */
|
|
1510
|
-
function SchemaField({ name, property, required, upload = false }) {
|
|
1693
|
+
function SchemaField({ name, property, required, upload = false, render }) {
|
|
1511
1694
|
const label = humanize(name);
|
|
1512
1695
|
const hint = property.description === void 0 ? {} : { hint: property.description };
|
|
1513
1696
|
const defaults = property.default === void 0 ? {} : { defaultValue: String(property.default) };
|
|
1514
|
-
switch (fieldKindFor(property, {
|
|
1697
|
+
switch (fieldKindFor(property, {
|
|
1698
|
+
upload,
|
|
1699
|
+
render
|
|
1700
|
+
})) {
|
|
1515
1701
|
case "file": return /* @__PURE__ */ jsx(FileField, {
|
|
1516
1702
|
name,
|
|
1517
1703
|
label,
|
|
@@ -1519,14 +1705,35 @@ function SchemaField({ name, property, required, upload = false }) {
|
|
|
1519
1705
|
upload: true,
|
|
1520
1706
|
...hint
|
|
1521
1707
|
});
|
|
1522
|
-
case "
|
|
1708
|
+
case "lines": return /* @__PURE__ */ jsx(TextAreaField, {
|
|
1523
1709
|
name,
|
|
1524
1710
|
label,
|
|
1525
1711
|
required,
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
...
|
|
1712
|
+
split: "lines",
|
|
1713
|
+
rows: 3,
|
|
1714
|
+
...hint
|
|
1715
|
+
});
|
|
1716
|
+
case "namedText": return /* @__PURE__ */ jsx(FileField, {
|
|
1717
|
+
name,
|
|
1718
|
+
label,
|
|
1719
|
+
required,
|
|
1720
|
+
read: "text",
|
|
1721
|
+
as: "namedText",
|
|
1722
|
+
...hint
|
|
1529
1723
|
});
|
|
1724
|
+
case "select": {
|
|
1725
|
+
const choices = schemaChoicesOf(property) ?? [];
|
|
1726
|
+
const numeric = choices.length > 0 && choices.every((value) => typeof value === "number");
|
|
1727
|
+
return /* @__PURE__ */ jsx(SelectField, {
|
|
1728
|
+
name,
|
|
1729
|
+
label,
|
|
1730
|
+
required,
|
|
1731
|
+
options: choices.map((value) => String(value)),
|
|
1732
|
+
...omitUndefined({ "data-aai-value": numeric ? "number" : void 0 }),
|
|
1733
|
+
...hint,
|
|
1734
|
+
...defaults
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1530
1737
|
case "checkbox": return /* @__PURE__ */ jsx(CheckboxField, {
|
|
1531
1738
|
name,
|
|
1532
1739
|
label,
|
|
@@ -1569,7 +1776,7 @@ function humanize(name) {
|
|
|
1569
1776
|
//#region src/components/workflow-pending-note.tsx
|
|
1570
1777
|
/** @jsxImportSource react */
|
|
1571
1778
|
/**
|
|
1572
|
-
* The one sentence a page says while a run is in flight —
|
|
1779
|
+
* The one sentence a page says while a run is in flight — five situations,
|
|
1573
1780
|
* one line each — as a muted line under the form, and nothing otherwise.
|
|
1574
1781
|
*
|
|
1575
1782
|
* Six template pages had each written the function under this, byte-identical
|
|
@@ -1592,9 +1799,18 @@ function humanize(name) {
|
|
|
1592
1799
|
*
|
|
1593
1800
|
* `startedHere`, `run` and `pending` are what {@link WorkflowSubmission}
|
|
1594
1801
|
* reports, which is why the prop is the submission itself rather than three
|
|
1595
|
-
* booleans a page would re-derive.
|
|
1596
|
-
*
|
|
1597
|
-
*
|
|
1802
|
+
* booleans a page would re-derive. The two situations past those three read
|
|
1803
|
+
* two more of its fields, and used to be the one page that wrote its own note:
|
|
1804
|
+
*
|
|
1805
|
+
* - **A run nothing will find again** (`scope: "none"` — `useWorkflowStream`,
|
|
1806
|
+
* whose run reads the recording as this page sends it and gives up on one
|
|
1807
|
+
* that stops arriving, or `recover: false`): "keep this tab open", because
|
|
1808
|
+
* a reload does not orphan that run, it ENDS it. A page telling a reader they
|
|
1809
|
+
* can close the tab would be wrong in exactly that mode.
|
|
1810
|
+
* - **A PAUSED upload** is that same fact arriving through a button, which is
|
|
1811
|
+
* why it is tested first and why its two sentences differ: with no run yet
|
|
1812
|
+
* a pause costs nothing, and in the streaming mode the run is already
|
|
1813
|
+
* watching an upload it will give up on.
|
|
1598
1814
|
*
|
|
1599
1815
|
* @example
|
|
1600
1816
|
* ```tsx
|
|
@@ -1610,15 +1826,18 @@ function humanize(name) {
|
|
|
1610
1826
|
*
|
|
1611
1827
|
* @public
|
|
1612
1828
|
*/
|
|
1613
|
-
function WorkflowPendingNote({ submission, subject = "run", scope
|
|
1829
|
+
function WorkflowPendingNote({ submission, subject = "run", scope, className }) {
|
|
1614
1830
|
if (!submission.pending) return null;
|
|
1615
1831
|
return /* @__PURE__ */ jsx("p", {
|
|
1616
1832
|
className: clsx("text-sm opacity-70", className),
|
|
1617
|
-
children: pendingNote(submission, subject, scope)
|
|
1833
|
+
children: pendingNote(submission, subject, scope ?? submission.scope ?? "tab")
|
|
1618
1834
|
});
|
|
1619
1835
|
}
|
|
1620
|
-
/** The sentence itself, over the
|
|
1836
|
+
/** The sentence itself, over the five states the doc above names. */
|
|
1621
1837
|
function pendingNote(submission, subject, scope) {
|
|
1838
|
+
const recoverable = scope !== "none";
|
|
1839
|
+
if (submission.upload?.paused === true) return recoverable ? "Upload paused — nothing has started yet, so resume whenever you are ready." : `Upload paused — resume within a few minutes: the ${subject} has already started and gives up on a file that stops arriving.`;
|
|
1840
|
+
if (!recoverable) return `Keep this tab open — the ${subject} is being made from what this page sends, so a reload ends it.`;
|
|
1622
1841
|
const here = scope === "browser" ? "this browser" : "this tab";
|
|
1623
1842
|
if (submission.startedHere) return scope === "browser" ? `You can close this tab — this browser will find the ${subject} again.` : `Reloading is safe — this page will find the ${subject} again.`;
|
|
1624
1843
|
if (submission.run === void 0) return `Looking for the ${subject} ${here} started earlier…`;
|
|
@@ -2059,6 +2278,58 @@ function parseLog(raw, max) {
|
|
|
2059
2278
|
if (!Array.isArray(parsed)) return [];
|
|
2060
2279
|
return trim(parsed.map(parseEntry).filter((e) => e !== void 0), max);
|
|
2061
2280
|
}
|
|
2281
|
+
//#endregion
|
|
2282
|
+
//#region src/dictation-transcriber.ts
|
|
2283
|
+
/**
|
|
2284
|
+
* `dictationTranscriber(url)` — the `transcribe` callback `useDictation` needs,
|
|
2285
|
+
* for a server answering the SDK's `handleDictationRequest`.
|
|
2286
|
+
*
|
|
2287
|
+
* Every page that dictates writes the same few lines: POST the clip, read
|
|
2288
|
+
* `{ text }`, and turn a refusal into an error whose message is the SERVER's
|
|
2289
|
+
* sentence ("Recording too long — keep it under 120 seconds", "Transcription
|
|
2290
|
+
* is rate limited — try again shortly") rather than a bare status. This is
|
|
2291
|
+
* those lines, against the one reply shape the SDK route answers with.
|
|
2292
|
+
*/
|
|
2293
|
+
/**
|
|
2294
|
+
* A `transcribe` for `useDictation` that POSTs each clip to `url` and
|
|
2295
|
+
* reads the `{ text } | { error }` reply `handleDictationRequest` answers with.
|
|
2296
|
+
*
|
|
2297
|
+
* Rejects with the server's own `error` sentence on a refusal, and with a
|
|
2298
|
+
* status-naming message when the reply is not that shape (a proxy's HTML error
|
|
2299
|
+
* page) — `useDictation` shows either as its `error`.
|
|
2300
|
+
*
|
|
2301
|
+
* @param url - The route, resolved like any `fetch` URL (relative to the page).
|
|
2302
|
+
*
|
|
2303
|
+
* @example
|
|
2304
|
+
* ```ts
|
|
2305
|
+
* import { dictationTranscriber } from "@alexkroman1/aai-ui";
|
|
2306
|
+
*
|
|
2307
|
+
* export const transcribe = dictationTranscriber("/api/dictation", {
|
|
2308
|
+
* headers: () => ({ authorization: `Bearer ${localStorage.getItem("token") ?? ""}` }),
|
|
2309
|
+
* });
|
|
2310
|
+
* ```
|
|
2311
|
+
*
|
|
2312
|
+
* @public
|
|
2313
|
+
*/
|
|
2314
|
+
function dictationTranscriber(url, options = {}) {
|
|
2315
|
+
return async (audio, { signal }) => {
|
|
2316
|
+
const send = options.fetch ?? fetch;
|
|
2317
|
+
const extra = typeof options.headers === "function" ? options.headers() : options.headers;
|
|
2318
|
+
const res = await send(url, {
|
|
2319
|
+
method: "POST",
|
|
2320
|
+
body: audio,
|
|
2321
|
+
headers: {
|
|
2322
|
+
"Content-Type": audio.type || "audio/wav",
|
|
2323
|
+
...extra
|
|
2324
|
+
},
|
|
2325
|
+
signal
|
|
2326
|
+
});
|
|
2327
|
+
const body = await res.json().catch(() => void 0);
|
|
2328
|
+
if (res.ok && isRecord(body) && typeof body.text === "string") return body.text;
|
|
2329
|
+
if (isRecord(body) && typeof body.error === "string") throw new Error(body.error);
|
|
2330
|
+
throw new Error(`Dictation failed (HTTP ${res.status})`);
|
|
2331
|
+
};
|
|
2332
|
+
}
|
|
2062
2333
|
/** Parse a text frame as one of the wire's server frames, or undefined. */
|
|
2063
2334
|
function parseServerFrame(json) {
|
|
2064
2335
|
const parsed = InboxServerFrameSchema.safeParse(safeJsonParse$1(json));
|
|
@@ -2853,6 +3124,131 @@ function useSubmissionState(recover) {
|
|
|
2853
3124
|
};
|
|
2854
3125
|
}
|
|
2855
3126
|
//#endregion
|
|
3127
|
+
//#region src/_submission-timing.ts
|
|
3128
|
+
/**
|
|
3129
|
+
* The press-to-settle stopwatch both submit hooks report as
|
|
3130
|
+
* `WorkflowSubmission.timing`: the mark `submit()` was called at, and the mark
|
|
3131
|
+
* the submission settled at, read off the one flag that spans both waits.
|
|
3132
|
+
*
|
|
3133
|
+
* It is the half of `transcription-workflow`'s `useTotalLatency` that is a
|
|
3134
|
+
* fact about the SUBMISSION rather than about the page: that template kept
|
|
3135
|
+
* `startedAt`/`frozenMs` in its own state, armed by its own submit handler
|
|
3136
|
+
* and cleared by its own Clear button — shadow state for two instants only
|
|
3137
|
+
* the hook can place exactly. The rendering half (the interval that re-draws
|
|
3138
|
+
* a running clock, the "before the run / inside it" split) stays on the page,
|
|
3139
|
+
* because it is the page that decides how to print a duration.
|
|
3140
|
+
*
|
|
3141
|
+
* Two details it would be easy to get wrong, both kept from the template:
|
|
3142
|
+
*
|
|
3143
|
+
* - **The settle is measured in the effect that sees `pending` fall**, so the
|
|
3144
|
+
* mark is the moment the run settled rather than whenever the page next
|
|
3145
|
+
* drew. And only after `pending` has been seen TRUE since the press — a
|
|
3146
|
+
* press that lands one render before the submission reports itself in
|
|
3147
|
+
* flight would otherwise freeze the clock at zero.
|
|
3148
|
+
* - **`performance.now()`, not `Date.now()`.** Monotonic; see
|
|
3149
|
+
* `WorkflowSubmissionTiming`.
|
|
3150
|
+
*/
|
|
3151
|
+
const NONE = {};
|
|
3152
|
+
/**
|
|
3153
|
+
* The stopwatch over `pending` — see the module doc.
|
|
3154
|
+
*
|
|
3155
|
+
* @param pending - The submission's own `pending`: true from the press until
|
|
3156
|
+
* the run settles, which is the whole of what the clock covers.
|
|
3157
|
+
*/
|
|
3158
|
+
function useSubmissionTiming(pending) {
|
|
3159
|
+
const [timing, setTiming] = useState(NONE);
|
|
3160
|
+
const began = useRef(false);
|
|
3161
|
+
useEffect(() => {
|
|
3162
|
+
if (timing.submittedAt === void 0 || timing.settledAt !== void 0) return;
|
|
3163
|
+
if (pending) {
|
|
3164
|
+
began.current = true;
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
if (began.current) {
|
|
3168
|
+
const settledAt = performance.now();
|
|
3169
|
+
setTiming((at) => at.settledAt === void 0 ? {
|
|
3170
|
+
...at,
|
|
3171
|
+
settledAt
|
|
3172
|
+
} : at);
|
|
3173
|
+
}
|
|
3174
|
+
}, [pending, timing]);
|
|
3175
|
+
return {
|
|
3176
|
+
timing,
|
|
3177
|
+
markSubmitted: useCallback(() => {
|
|
3178
|
+
began.current = false;
|
|
3179
|
+
setTiming({ submittedAt: performance.now() });
|
|
3180
|
+
}, []),
|
|
3181
|
+
clear: useCallback(() => {
|
|
3182
|
+
began.current = false;
|
|
3183
|
+
setTiming(NONE);
|
|
3184
|
+
}, [])
|
|
3185
|
+
};
|
|
3186
|
+
}
|
|
3187
|
+
//#endregion
|
|
3188
|
+
//#region src/_submission.ts
|
|
3189
|
+
/**
|
|
3190
|
+
* Everything a submit hook is, bar its BODY.
|
|
3191
|
+
*
|
|
3192
|
+
* `useWorkflowSubmit` (`use-workflow-form.ts`) and `useWorkflowStream`
|
|
3193
|
+
* (`use-workflow-stream.ts`) differ in one thing — when the run is created
|
|
3194
|
+
* relative to the bytes — and that one thing is the submission body each hands
|
|
3195
|
+
* to the form's statechart. Everything after the body was written out twice,
|
|
3196
|
+
* identically: the watch over the run id (`useWorkflowRun`), the two controls
|
|
3197
|
+
* bound to it (`_run-controls.ts`), `pending` as "the machine is busy or the
|
|
3198
|
+
* watch is still polling", the press-to-settle stopwatch
|
|
3199
|
+
* (`_submission-timing.ts`), a `reset` that clears both, and the fourteen-field
|
|
3200
|
+
* `WorkflowSubmission` assembled from all of it. Two copies of a return object
|
|
3201
|
+
* that have to agree field for field are two copies that can stop agreeing —
|
|
3202
|
+
* the stream hook's type is an ALIAS of the form hook's for the same reason —
|
|
3203
|
+
* so the tail is one function, and each hook keeps only what makes it itself.
|
|
3204
|
+
*/
|
|
3205
|
+
/**
|
|
3206
|
+
* The shared tail of a submit hook: the statechart, the watch, the controls,
|
|
3207
|
+
* the stopwatch, and the `WorkflowSubmission` built from them. See the module
|
|
3208
|
+
* doc.
|
|
3209
|
+
*/
|
|
3210
|
+
function useSubmission(options) {
|
|
3211
|
+
const { getClient, api, intervalMs, recover, scope, begin } = options;
|
|
3212
|
+
const form = useSubmissionState(recover);
|
|
3213
|
+
const { runId, actions } = form;
|
|
3214
|
+
const tracked = useWorkflowRun(runId, omitUndefined({
|
|
3215
|
+
api,
|
|
3216
|
+
intervalMs
|
|
3217
|
+
}));
|
|
3218
|
+
const { wake, cancel } = useRunControls(runId, getClient);
|
|
3219
|
+
const pending = form.busy || tracked.polling;
|
|
3220
|
+
const { timing, markSubmitted, clear: clearTiming } = useSubmissionTiming(pending);
|
|
3221
|
+
const submit = useCallback((input) => {
|
|
3222
|
+
markSubmitted();
|
|
3223
|
+
const { gate, work } = begin(input, getClient());
|
|
3224
|
+
return actions.submit(gate, work);
|
|
3225
|
+
}, [
|
|
3226
|
+
begin,
|
|
3227
|
+
getClient,
|
|
3228
|
+
actions,
|
|
3229
|
+
markSubmitted
|
|
3230
|
+
]);
|
|
3231
|
+
return {
|
|
3232
|
+
submit,
|
|
3233
|
+
submitForm: submit,
|
|
3234
|
+
reset: useCallback(() => {
|
|
3235
|
+
clearTiming();
|
|
3236
|
+
actions.reset();
|
|
3237
|
+
}, [actions, clearTiming]),
|
|
3238
|
+
wake,
|
|
3239
|
+
cancel,
|
|
3240
|
+
pauseUpload: actions.pauseUpload,
|
|
3241
|
+
resumeUpload: actions.resumeUpload,
|
|
3242
|
+
run: tracked.run,
|
|
3243
|
+
startedHere: form.startedHere,
|
|
3244
|
+
pending,
|
|
3245
|
+
upload: form.upload,
|
|
3246
|
+
error: form.startError ?? tracked.error,
|
|
3247
|
+
timing,
|
|
3248
|
+
scope
|
|
3249
|
+
};
|
|
3250
|
+
}
|
|
3251
|
+
//#endregion
|
|
2856
3252
|
//#region src/_workflow-files.ts
|
|
2857
3253
|
/**
|
|
2858
3254
|
* Which of a submitted form's values are FILES.
|
|
@@ -3424,6 +3820,20 @@ function mintRunKey(storage) {
|
|
|
3424
3820
|
return minted;
|
|
3425
3821
|
}
|
|
3426
3822
|
/**
|
|
3823
|
+
* Refuse a caller's key that is empty or whitespace.
|
|
3824
|
+
*
|
|
3825
|
+
* A key is the lookup CAPABILITY (see the module doc), so one that computes to
|
|
3826
|
+
* `""` is not "no key": it is ONE key, SHARED by every visitor whose key came
|
|
3827
|
+
* out the same way — `user?.id ?? ""` before the login resolves is the natural
|
|
3828
|
+
* way to write it — and they then record and recover each other's runs. The
|
|
3829
|
+
* server refuses the same value with a 400 (`workflow/api/runs.ts`), but a
|
|
3830
|
+
* refusal at the hook names the line that computed it.
|
|
3831
|
+
*/
|
|
3832
|
+
function assertUsableRunKey(key) {
|
|
3833
|
+
if (key.trim() !== "") return;
|
|
3834
|
+
throw new Error(`useWorkflowSubmit({ key }) was given ${JSON.stringify(key)}, an empty key. A key is the lookup capability runs are recorded under, so an empty one would be SHARED by every visitor whose key computed to "" (a \`user?.id ?? ""\` before login is the usual cause). Pass undefined until the key is known — the hook mints a per-page key of its own — or a non-blank string.`);
|
|
3835
|
+
}
|
|
3836
|
+
/**
|
|
3427
3837
|
* The key `useWorkflowSubmit` uses when the page named none.
|
|
3428
3838
|
*
|
|
3429
3839
|
* Two things it does that a plain `useRunKey()` at the call site cannot, and
|
|
@@ -3443,12 +3853,15 @@ function mintRunKey(storage) {
|
|
|
3443
3853
|
*
|
|
3444
3854
|
* @param explicit - The caller's own key, or undefined for a page with none.
|
|
3445
3855
|
* @returns The key to record runs under and look them up by.
|
|
3856
|
+
* @throws When the caller's key is empty or whitespace — see
|
|
3857
|
+
* {@link assertUsableRunKey}.
|
|
3446
3858
|
*
|
|
3447
3859
|
* @internal
|
|
3448
3860
|
*/
|
|
3449
3861
|
function useDefaultRunKey(explicit) {
|
|
3450
|
-
|
|
3451
|
-
|
|
3862
|
+
if (typeof explicit === "string") assertUsableRunKey(explicit);
|
|
3863
|
+
const [minted] = useState(() => typeof explicit === "string" ? "" : mintRunKey(explicit?.storage ?? "session"));
|
|
3864
|
+
return typeof explicit === "string" ? explicit : minted;
|
|
3452
3865
|
}
|
|
3453
3866
|
/**
|
|
3454
3867
|
* A lookup key for `useWorkflowSubmit({ key })`, stable across reloads.
|
|
@@ -3473,7 +3886,8 @@ function useRunKey(options = {}) {
|
|
|
3473
3886
|
* `useWorkflowRun` (`workflow-client.ts`) watches a run you already have.
|
|
3474
3887
|
* This is what comes before it: `useWorkflowSubmit` starts a run and hands the
|
|
3475
3888
|
* id straight to `useWorkflowRun`. Its sibling `useWorkflows` — the listing
|
|
3476
|
-
* `<WorkflowFields>` renders a form from — is `use-workflows.ts`.
|
|
3889
|
+
* `<WorkflowFields>` renders a form from — is `use-workflows.ts`. What it hands
|
|
3890
|
+
* back is `WorkflowSubmission` (`workflow-submission.ts`).
|
|
3477
3891
|
*
|
|
3478
3892
|
* ## `useWorkflowSubmit` — a form's two halves in one hook
|
|
3479
3893
|
*
|
|
@@ -3501,89 +3915,53 @@ function useRunKey(options = {}) {
|
|
|
3501
3915
|
* followed from the same id either way.
|
|
3502
3916
|
*/
|
|
3503
3917
|
/**
|
|
3504
|
-
*
|
|
3505
|
-
*
|
|
3506
|
-
*
|
|
3507
|
-
*
|
|
3508
|
-
* `run.status === "completed"` narrows to a typed `run.output`.
|
|
3509
|
-
*
|
|
3510
|
-
* It used to be the OUTPUT type alone, and the asymmetry was the bug: a page
|
|
3511
|
-
* already wrote `WorkflowOutputOf<typeof digest>` to get the output, while
|
|
3512
|
-
* `submit` took `unknown`, so `submit({ ur1: 42 })` compiled and arrived as a
|
|
3513
|
-
* 400 in the browser. Naming the def instead types the input from the same
|
|
3514
|
-
* declaration — and `import type` is ERASED, so it costs the bundle nothing.
|
|
3515
|
-
* Passing an output type where a def belongs is now a compile error rather
|
|
3516
|
-
* than a silent loss of typing, which is the point.
|
|
3517
|
-
*
|
|
3518
|
-
* @example
|
|
3519
|
-
* ```tsx no-check
|
|
3520
|
-
* import { Form, SubmitButton, TextField, useWorkflowSubmit } from "@alexkroman1/aai-ui";
|
|
3521
|
-
* import type { digest } from "./agent.ts";
|
|
3522
|
-
*
|
|
3523
|
-
* function DigestForm() {
|
|
3524
|
-
* const { submit, run, pending, error } = useWorkflowSubmit<typeof digest>("digest");
|
|
3525
|
-
* return (
|
|
3526
|
-
* <Form onSubmit={(values) => submit(values)} error={error}>
|
|
3527
|
-
* <TextField name="url" label="Link" type="url" required />
|
|
3528
|
-
* <SubmitButton pending={pending}>Digest</SubmitButton>
|
|
3529
|
-
* {run?.status === "completed" && <p>{run.output.title}</p>}
|
|
3530
|
-
* </Form>
|
|
3531
|
-
* );
|
|
3532
|
-
* }
|
|
3533
|
-
* ```
|
|
3534
|
-
*
|
|
3535
|
-
* @public
|
|
3918
|
+
* Who can find the run again, from the two options that decide it — see
|
|
3919
|
+
* `WorkflowSubmissionScope`. A page-supplied string key is read as
|
|
3920
|
+
* `"browser"`: it is at least that wide (an account id is wider still), and
|
|
3921
|
+
* "close this tab" is true of it.
|
|
3536
3922
|
*/
|
|
3923
|
+
function scopeOf(key, recover) {
|
|
3924
|
+
if (!recover) return "none";
|
|
3925
|
+
if (typeof key === "string") return "browser";
|
|
3926
|
+
return key?.storage === "local" ? "browser" : "tab";
|
|
3927
|
+
}
|
|
3537
3928
|
function useWorkflowSubmit(workflow, options = {}) {
|
|
3538
3929
|
const { api, recover = true, wait, intervalMs, parallel } = options;
|
|
3539
3930
|
const key = useDefaultRunKey(options.key);
|
|
3931
|
+
const scope = scopeOf(options.key, recover);
|
|
3540
3932
|
const getClient = useWorkflowApiRef(api);
|
|
3541
|
-
const
|
|
3542
|
-
workflow,
|
|
3543
|
-
key,
|
|
3544
|
-
enabled: recover,
|
|
3545
|
-
getClient
|
|
3546
|
-
});
|
|
3547
|
-
const { runId, actions } = form;
|
|
3548
|
-
const tracked = useWorkflowRun(runId, omitUndefined({
|
|
3549
|
-
api,
|
|
3550
|
-
intervalMs
|
|
3551
|
-
}));
|
|
3552
|
-
const { wake, cancel } = useRunControls(runId, getClient);
|
|
3553
|
-
const submit = useCallback((input) => {
|
|
3554
|
-
const client = getClient();
|
|
3933
|
+
const begin = useCallback((input, client) => {
|
|
3555
3934
|
const session = createUploadSession(workflow);
|
|
3556
|
-
return
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3935
|
+
return {
|
|
3936
|
+
gate: session.gate,
|
|
3937
|
+
work: async ({ progress, started }) => {
|
|
3938
|
+
const options = omitUndefined({ key });
|
|
3939
|
+
const stored = await uploadFiles(client, input, coalesceUploadReports(progress), parallel, session);
|
|
3940
|
+
started(wait === void 0 ? await client.start(workflow, stored, options) : (await client.startAndWait(workflow, stored, {
|
|
3941
|
+
...options,
|
|
3942
|
+
wait
|
|
3943
|
+
})).runId);
|
|
3944
|
+
}
|
|
3945
|
+
};
|
|
3565
3946
|
}, [
|
|
3566
3947
|
workflow,
|
|
3567
3948
|
key,
|
|
3568
3949
|
wait,
|
|
3569
|
-
parallel
|
|
3570
|
-
getClient,
|
|
3571
|
-
actions
|
|
3950
|
+
parallel
|
|
3572
3951
|
]);
|
|
3573
|
-
return {
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
};
|
|
3952
|
+
return useSubmission({
|
|
3953
|
+
getClient,
|
|
3954
|
+
api,
|
|
3955
|
+
intervalMs,
|
|
3956
|
+
recover: {
|
|
3957
|
+
workflow,
|
|
3958
|
+
key,
|
|
3959
|
+
enabled: recover,
|
|
3960
|
+
getClient
|
|
3961
|
+
},
|
|
3962
|
+
scope,
|
|
3963
|
+
begin
|
|
3964
|
+
});
|
|
3587
3965
|
}
|
|
3588
3966
|
//#endregion
|
|
3589
3967
|
//#region src/_page-shell.tsx
|
|
@@ -3877,7 +4255,7 @@ function phoneE164(typed, options = {}) {
|
|
|
3877
4255
|
* @param method - The route's method.
|
|
3878
4256
|
* @param path - The route's path as declared, starting with `/`; `?query` allowed.
|
|
3879
4257
|
* @param body - Sent as JSON when given.
|
|
3880
|
-
* @param options - `client`, `baseUrl` and `signal`; see {@link RouteFetchOptions}.
|
|
4258
|
+
* @param options - `client`, `baseUrl`, `headers` and `signal`; see {@link RouteFetchOptions}.
|
|
3881
4259
|
* @returns The parsed JSON body.
|
|
3882
4260
|
*
|
|
3883
4261
|
* @public
|
|
@@ -3886,13 +4264,18 @@ async function routeFetch(method, path, body, options = {}) {
|
|
|
3886
4264
|
const base = options.baseUrl ?? pageBaseUrl();
|
|
3887
4265
|
const url = new URL(`api${path.startsWith("/") ? path : `/${path}`}`, base);
|
|
3888
4266
|
if (options.client) url.searchParams.set("client", options.client);
|
|
4267
|
+
const given = Object.fromEntries(Object.entries(options.headers ?? {}).map(([name, value]) => [name.toLowerCase(), value]));
|
|
4268
|
+
const headers = omitUndefined({
|
|
4269
|
+
"content-type": body === void 0 ? void 0 : "application/json",
|
|
4270
|
+
...given
|
|
4271
|
+
});
|
|
3889
4272
|
const res = await fetch(url, {
|
|
3890
4273
|
method,
|
|
3891
|
-
...
|
|
3892
|
-
headers:
|
|
3893
|
-
body: JSON.stringify(body)
|
|
3894
|
-
|
|
3895
|
-
|
|
4274
|
+
...omitUndefined({
|
|
4275
|
+
headers: Object.keys(headers).length > 0 ? headers : void 0,
|
|
4276
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
4277
|
+
signal: options.signal
|
|
4278
|
+
})
|
|
3896
4279
|
});
|
|
3897
4280
|
const json = await res.json().catch(() => ({}));
|
|
3898
4281
|
if (!res.ok) {
|
|
@@ -4040,12 +4423,20 @@ function useStoredValue(source, initial) {
|
|
|
4040
4423
|
* dropped — `useWorkflowRuns`' rule), a read landing after unmount, and a
|
|
4041
4424
|
* failure rendered as an empty answer. `data` is KEPT through a failed re-read,
|
|
4042
4425
|
* so a poll that hiccups does not blank the panel; `error` says it is stale.
|
|
4426
|
+
* It is NOT kept across a change of `path`: the answer is remembered with the
|
|
4427
|
+
* path it came from, so a page switching from one record to the next never
|
|
4428
|
+
* shows the previous one under the new id while the first read is in flight.
|
|
4429
|
+
*
|
|
4430
|
+
* The poll is `repeatUntil`'s loop (`_repeat-until.ts`), re-armed from the
|
|
4431
|
+
* SETTLED read rather than on an interval — a slow route would otherwise
|
|
4432
|
+
* stack overlapping reads — and stopped by `until`, by a change of what is
|
|
4433
|
+
* read, and by unmount, with the in-flight request aborted on the same signal.
|
|
4043
4434
|
*
|
|
4044
4435
|
* @module
|
|
4045
4436
|
*/
|
|
4046
4437
|
/**
|
|
4047
4438
|
* `GET` one of the agent's own routes, on mount, on `reload()` and every
|
|
4048
|
-
* `pollMs` — see this module's doc.
|
|
4439
|
+
* `pollMs` until `until` says so — see this module's doc.
|
|
4049
4440
|
*
|
|
4050
4441
|
* @example A list that re-reads after each delete
|
|
4051
4442
|
* ```tsx
|
|
@@ -4075,9 +4466,24 @@ function useStoredValue(source, initial) {
|
|
|
4075
4466
|
* }
|
|
4076
4467
|
* ```
|
|
4077
4468
|
*
|
|
4469
|
+
* @example A call followed until it is over
|
|
4470
|
+
* ```tsx
|
|
4471
|
+
* import { useRoute } from "@alexkroman1/aai-ui";
|
|
4472
|
+
*
|
|
4473
|
+
* type Call = { status: string; transcript: { role: string; text: string }[] };
|
|
4474
|
+
*
|
|
4475
|
+
* function LiveCall({ id }: { id: string }) {
|
|
4476
|
+
* const { data } = useRoute<Call>(`/calls/${id}`, {
|
|
4477
|
+
* pollMs: 2000,
|
|
4478
|
+
* until: (call) => call.status === "completed",
|
|
4479
|
+
* });
|
|
4480
|
+
* return <ol>{data?.transcript.map((line, i) => <li key={String(i)}>{line.text}</li>)}</ol>;
|
|
4481
|
+
* }
|
|
4482
|
+
* ```
|
|
4483
|
+
*
|
|
4078
4484
|
* @typeParam T - The shape the route answers. Not checked.
|
|
4079
4485
|
* @param path - The route's path as declared, without `/api`. `null` reads nothing.
|
|
4080
|
-
* @param options - `pollMs` and `
|
|
4486
|
+
* @param options - `pollMs`, `until`, `client` and `headers`; see {@link UseRouteOptions}.
|
|
4081
4487
|
* @returns The answer, the last error, and `reload`; see {@link UseRouteResult}.
|
|
4082
4488
|
*
|
|
4083
4489
|
* @public
|
|
@@ -4085,49 +4491,85 @@ function useStoredValue(source, initial) {
|
|
|
4085
4491
|
function useRoute(path, options = {}) {
|
|
4086
4492
|
const session = useOptionalSessionCore();
|
|
4087
4493
|
const { pollMs, client } = options;
|
|
4088
|
-
|
|
4089
|
-
const [
|
|
4494
|
+
if (pollMs !== void 0 && !(Number.isFinite(pollMs) && pollMs >= 0)) throw new Error(`useRoute: pollMs must be a non-negative number, not ${String(pollMs)}`);
|
|
4495
|
+
const [answer, setAnswer] = useState(void 0);
|
|
4090
4496
|
const [loading, setLoading] = useState(path !== null);
|
|
4497
|
+
const perRequest = useRef({
|
|
4498
|
+
until: options.until,
|
|
4499
|
+
headers: options.headers
|
|
4500
|
+
});
|
|
4501
|
+
perRequest.current = {
|
|
4502
|
+
until: options.until,
|
|
4503
|
+
headers: options.headers
|
|
4504
|
+
};
|
|
4091
4505
|
const epochRef = useRef(void 0);
|
|
4092
4506
|
epochRef.current ??= createEpoch();
|
|
4093
4507
|
const epoch = epochRef.current;
|
|
4094
|
-
|
|
4095
|
-
|
|
4508
|
+
/**
|
|
4509
|
+
* One read. Resolves whether the poll (if any) is FINISHED: `until` was
|
|
4510
|
+
* satisfied. A read that was superseded by a newer one (a `reload()`, or the
|
|
4511
|
+
* unmount's bump) answers `false` — its ANSWER is nobody's, but the poll is
|
|
4512
|
+
* still owed its next tick: `true` here would stop `repeatUntil` for good on
|
|
4513
|
+
* the first reload during a poll. The unmount is what stops the loop, through
|
|
4514
|
+
* its own `stop()`, and a loop whose signal is aborted never re-arms.
|
|
4515
|
+
*/
|
|
4516
|
+
const read = useCallback(async (signal) => {
|
|
4517
|
+
if (path === null) return true;
|
|
4096
4518
|
epoch.bump();
|
|
4097
4519
|
const mine = epoch.current();
|
|
4098
4520
|
setLoading(true);
|
|
4099
|
-
const
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4521
|
+
const { until, headers } = perRequest.current;
|
|
4522
|
+
try {
|
|
4523
|
+
const answer = await routeFetch("GET", path, void 0, { ...omitUndefined({
|
|
4524
|
+
client: client ?? session?.identity.clientId(),
|
|
4525
|
+
headers,
|
|
4526
|
+
signal
|
|
4527
|
+
}) });
|
|
4528
|
+
if (!epoch.isCurrent(mine)) return false;
|
|
4529
|
+
setAnswer({
|
|
4530
|
+
path,
|
|
4531
|
+
data: answer,
|
|
4532
|
+
error: void 0
|
|
4533
|
+
});
|
|
4104
4534
|
setLoading(false);
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4535
|
+
return until?.(answer) === true;
|
|
4536
|
+
} catch (err) {
|
|
4537
|
+
if (!epoch.isCurrent(mine)) return false;
|
|
4538
|
+
setAnswer((prior) => ({
|
|
4539
|
+
path,
|
|
4540
|
+
data: prior?.path === path ? prior.data : void 0,
|
|
4541
|
+
error: errorMessage$1(err)
|
|
4542
|
+
}));
|
|
4108
4543
|
setLoading(false);
|
|
4109
|
-
|
|
4544
|
+
return false;
|
|
4545
|
+
}
|
|
4110
4546
|
}, [
|
|
4111
4547
|
path,
|
|
4112
4548
|
client,
|
|
4113
4549
|
session,
|
|
4114
4550
|
epoch
|
|
4115
4551
|
]);
|
|
4552
|
+
const reload = useCallback(() => {
|
|
4553
|
+
read();
|
|
4554
|
+
}, [read]);
|
|
4116
4555
|
useEffect(() => {
|
|
4117
|
-
|
|
4118
|
-
|
|
4556
|
+
const stop = pollMs !== void 0 && pollMs > 0 ? repeatUntil(pollMs, read) : ((controller) => {
|
|
4557
|
+
read(controller.signal);
|
|
4558
|
+
return () => controller.abort();
|
|
4559
|
+
})(new AbortController());
|
|
4119
4560
|
return () => {
|
|
4120
|
-
|
|
4561
|
+
stop();
|
|
4121
4562
|
epoch.bump();
|
|
4122
4563
|
};
|
|
4123
4564
|
}, [
|
|
4124
|
-
|
|
4565
|
+
read,
|
|
4125
4566
|
pollMs,
|
|
4126
4567
|
epoch
|
|
4127
4568
|
]);
|
|
4569
|
+
const current = answer?.path === path ? answer : void 0;
|
|
4128
4570
|
return {
|
|
4129
|
-
data,
|
|
4130
|
-
error,
|
|
4571
|
+
data: current?.data,
|
|
4572
|
+
error: current?.error,
|
|
4131
4573
|
loading,
|
|
4132
4574
|
reload
|
|
4133
4575
|
};
|
|
@@ -4507,6 +4949,209 @@ function append(list, event) {
|
|
|
4507
4949
|
return item ? [...list, item] : void 0;
|
|
4508
4950
|
}
|
|
4509
4951
|
//#endregion
|
|
4952
|
+
//#region src/use-dictation.ts
|
|
4953
|
+
/**
|
|
4954
|
+
* `useDictation` — speak into a text box: record a clip, have it transcribed,
|
|
4955
|
+
* hand the words to the page.
|
|
4956
|
+
*
|
|
4957
|
+
* Not a session. There is no agent on the other end and no socket: the clip
|
|
4958
|
+
* goes to AssemblyAI's dictation API in ONE request, through a `transcribe`
|
|
4959
|
+
* callback the page supplies — on the far side of which is a server holding
|
|
4960
|
+
* the key, calling `transcribeDictation` from `@alexkroman1/aai/experimental`.
|
|
4961
|
+
* The key never reaches the browser, which is why this hook takes a callback
|
|
4962
|
+
* rather than a credential.
|
|
4963
|
+
*
|
|
4964
|
+
* The two ends that are not this hook are published too: `dictationTranscriber`
|
|
4965
|
+
* is the `transcribe` for a server answering `handleDictationRequest`'s
|
|
4966
|
+
* `{ text } | { error }`, and `appendDictation` adds the words to a draft.
|
|
4967
|
+
*
|
|
4968
|
+
* What it owns, because each is a way a hand-written mic button goes wrong:
|
|
4969
|
+
*
|
|
4970
|
+
* - **The recording indicator goes out.** Every way out — stop, cancel, the
|
|
4971
|
+
* length cap, an unmount mid-recording — releases the microphone.
|
|
4972
|
+
* - **A late answer lands nowhere.** A transcription that settles after a
|
|
4973
|
+
* cancel, a newer recording or an unmount is dropped, never typed into the
|
|
4974
|
+
* box the user has moved on from.
|
|
4975
|
+
* - **The button cannot be pressed into a bad state.** `buttonProps` keeps a
|
|
4976
|
+
* recording stoppable while `disabled`, and takes no press mid-transition.
|
|
4977
|
+
* - **The clip fits the endpoint.** Recording stops by itself at `maxSeconds`
|
|
4978
|
+
* and is transcribed, rather than growing into a clip the service refuses
|
|
4979
|
+
* whole.
|
|
4980
|
+
*/
|
|
4981
|
+
const openRecorder = async (opts) => {
|
|
4982
|
+
const [audio, captureWorkletSrc] = await Promise.all([import("./audio/index.js"), import("./worklets/capture-processor.js").then((m) => m.default)]);
|
|
4983
|
+
return audio.openDictationRecorder({
|
|
4984
|
+
...opts,
|
|
4985
|
+
captureWorkletSrc
|
|
4986
|
+
});
|
|
4987
|
+
};
|
|
4988
|
+
/** Stop `rec` and transcribe the clip; `""` when aborted before the request. */
|
|
4989
|
+
async function transcribeRecording(rec, signal, transcribe) {
|
|
4990
|
+
const wav = await rec.stop();
|
|
4991
|
+
if (signal.aborted) return "";
|
|
4992
|
+
return (await transcribe(new Blob([wav], { type: "audio/wav" }), { signal })).trim();
|
|
4993
|
+
}
|
|
4994
|
+
function browserCanRecord() {
|
|
4995
|
+
return typeof navigator !== "undefined" && typeof navigator.mediaDevices?.getUserMedia === "function" && typeof AudioContext !== "undefined" && typeof AudioWorkletNode !== "undefined";
|
|
4996
|
+
}
|
|
4997
|
+
/**
|
|
4998
|
+
* Record what the user says and hand it back as text — a microphone button
|
|
4999
|
+
* for a text box.
|
|
5000
|
+
*
|
|
5001
|
+
* @example
|
|
5002
|
+
* ```tsx
|
|
5003
|
+
* import { appendDictation, dictationTranscriber, useDictation } from "@alexkroman1/aai-ui";
|
|
5004
|
+
* import { useState } from "react";
|
|
5005
|
+
*
|
|
5006
|
+
* // `POST /dictation` on your own server is `handleDictationRequest`
|
|
5007
|
+
* // (`@alexkroman1/aai/experimental`), which holds the API key.
|
|
5008
|
+
* const transcribe = dictationTranscriber("/dictation");
|
|
5009
|
+
*
|
|
5010
|
+
* export function SpokenNote() {
|
|
5011
|
+
* const [draft, setDraft] = useState("");
|
|
5012
|
+
* const dictation = useDictation({
|
|
5013
|
+
* transcribe,
|
|
5014
|
+
* onText: (text) => setDraft((d) => appendDictation(d, text)),
|
|
5015
|
+
* });
|
|
5016
|
+
* return (
|
|
5017
|
+
* <div>
|
|
5018
|
+
* <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />
|
|
5019
|
+
* {dictation.supported && (
|
|
5020
|
+
* <button type="button" {...dictation.buttonProps}>
|
|
5021
|
+
* {dictation.phase === "recording" ? "Stop" : "Speak"}
|
|
5022
|
+
* </button>
|
|
5023
|
+
* )}
|
|
5024
|
+
* </div>
|
|
5025
|
+
* );
|
|
5026
|
+
* }
|
|
5027
|
+
* ```
|
|
5028
|
+
*
|
|
5029
|
+
* @public
|
|
5030
|
+
*/
|
|
5031
|
+
function useDictation(options) {
|
|
5032
|
+
return useDictationWith(options, openRecorder);
|
|
5033
|
+
}
|
|
5034
|
+
/**
|
|
5035
|
+
* {@link useDictation} over an injected recorder — the seam the specs drive.
|
|
5036
|
+
* Not on the barrel.
|
|
5037
|
+
*/
|
|
5038
|
+
function useDictationWith(options, open) {
|
|
5039
|
+
const [phase, setPhase] = useState("idle");
|
|
5040
|
+
const [error, setError] = useState(null);
|
|
5041
|
+
const supported = useMemo(browserCanRecord, []);
|
|
5042
|
+
const latest = useRef(options);
|
|
5043
|
+
latest.current = options;
|
|
5044
|
+
const openRef = useRef(open);
|
|
5045
|
+
openRef.current = open;
|
|
5046
|
+
const epoch = useRef(createEpoch()).current;
|
|
5047
|
+
const phaseRef = useRef("idle");
|
|
5048
|
+
const recorder = useRef(null);
|
|
5049
|
+
const inflight = useRef(null);
|
|
5050
|
+
const cap = useRef(null);
|
|
5051
|
+
const move = useCallback((next) => {
|
|
5052
|
+
phaseRef.current = next;
|
|
5053
|
+
setPhase(next);
|
|
5054
|
+
}, []);
|
|
5055
|
+
const clearCap = useCallback(() => {
|
|
5056
|
+
if (cap.current !== null) clearTimeout(cap.current);
|
|
5057
|
+
cap.current = null;
|
|
5058
|
+
}, []);
|
|
5059
|
+
/** Drop whatever is running, releasing the microphone. Leaves `phase` alone. */
|
|
5060
|
+
const abandon = useCallback(() => {
|
|
5061
|
+
epoch.bump();
|
|
5062
|
+
clearCap();
|
|
5063
|
+
inflight.current?.abort();
|
|
5064
|
+
inflight.current = null;
|
|
5065
|
+
const rec = recorder.current;
|
|
5066
|
+
recorder.current = null;
|
|
5067
|
+
rec?.cancel();
|
|
5068
|
+
}, [epoch, clearCap]);
|
|
5069
|
+
const stop = useCallback(() => {
|
|
5070
|
+
const rec = recorder.current;
|
|
5071
|
+
if (phaseRef.current !== "recording" || !rec) return;
|
|
5072
|
+
recorder.current = null;
|
|
5073
|
+
clearCap();
|
|
5074
|
+
move("transcribing");
|
|
5075
|
+
const gen = epoch.current();
|
|
5076
|
+
const controller = new AbortController();
|
|
5077
|
+
inflight.current = controller;
|
|
5078
|
+
const settle = () => {
|
|
5079
|
+
inflight.current = null;
|
|
5080
|
+
move("idle");
|
|
5081
|
+
};
|
|
5082
|
+
transcribeRecording(rec, controller.signal, latest.current.transcribe).then((text) => {
|
|
5083
|
+
if (!epoch.isCurrent(gen)) return;
|
|
5084
|
+
if (text) latest.current.onText(text);
|
|
5085
|
+
settle();
|
|
5086
|
+
}, (err) => {
|
|
5087
|
+
if (!epoch.isCurrent(gen)) return;
|
|
5088
|
+
setError(errorMessage$1(err));
|
|
5089
|
+
settle();
|
|
5090
|
+
});
|
|
5091
|
+
}, [
|
|
5092
|
+
epoch,
|
|
5093
|
+
clearCap,
|
|
5094
|
+
move
|
|
5095
|
+
]);
|
|
5096
|
+
const start = useCallback(() => {
|
|
5097
|
+
if (phaseRef.current !== "idle" || latest.current.disabled === true) return;
|
|
5098
|
+
setError(null);
|
|
5099
|
+
move("starting");
|
|
5100
|
+
const gen = epoch.current();
|
|
5101
|
+
const maxSeconds = latest.current.maxSeconds ?? DICTATION_MAX_SECONDS;
|
|
5102
|
+
openRef.current({
|
|
5103
|
+
sampleRate: DICTATION_SAMPLE_RATE,
|
|
5104
|
+
maxSeconds
|
|
5105
|
+
}).then((rec) => {
|
|
5106
|
+
if (!epoch.isCurrent(gen)) {
|
|
5107
|
+
rec.cancel();
|
|
5108
|
+
return;
|
|
5109
|
+
}
|
|
5110
|
+
recorder.current = rec;
|
|
5111
|
+
move("recording");
|
|
5112
|
+
cap.current = setTimeout(stop, maxSeconds * 1e3);
|
|
5113
|
+
}).catch((err) => {
|
|
5114
|
+
if (!epoch.isCurrent(gen)) return;
|
|
5115
|
+
setError(errorMessage$1(err));
|
|
5116
|
+
move("idle");
|
|
5117
|
+
});
|
|
5118
|
+
}, [
|
|
5119
|
+
epoch,
|
|
5120
|
+
move,
|
|
5121
|
+
stop
|
|
5122
|
+
]);
|
|
5123
|
+
const cancel = useCallback(() => {
|
|
5124
|
+
if (phaseRef.current === "idle") return;
|
|
5125
|
+
abandon();
|
|
5126
|
+
move("idle");
|
|
5127
|
+
}, [abandon, move]);
|
|
5128
|
+
const toggle = useCallback(() => {
|
|
5129
|
+
if (phaseRef.current === "idle") start();
|
|
5130
|
+
else if (phaseRef.current === "recording") stop();
|
|
5131
|
+
}, [start, stop]);
|
|
5132
|
+
useEffect(() => abandon, [abandon]);
|
|
5133
|
+
const recording = phase === "recording";
|
|
5134
|
+
const blocked = phase === "starting" || phase === "transcribing" || options.disabled === true && !recording;
|
|
5135
|
+
return {
|
|
5136
|
+
phase,
|
|
5137
|
+
error,
|
|
5138
|
+
supported,
|
|
5139
|
+
start,
|
|
5140
|
+
stop,
|
|
5141
|
+
cancel,
|
|
5142
|
+
toggle,
|
|
5143
|
+
buttonProps: useMemo(() => ({
|
|
5144
|
+
onClick: toggle,
|
|
5145
|
+
disabled: blocked,
|
|
5146
|
+
"aria-pressed": recording
|
|
5147
|
+
}), [
|
|
5148
|
+
toggle,
|
|
5149
|
+
blocked,
|
|
5150
|
+
recording
|
|
5151
|
+
])
|
|
5152
|
+
};
|
|
5153
|
+
}
|
|
5154
|
+
//#endregion
|
|
4510
5155
|
//#region src/use-download-url.ts
|
|
4511
5156
|
/**
|
|
4512
5157
|
* `useDownloadUrl` — an upload id a run produced, as something `<audio>`,
|
|
@@ -5367,24 +6012,6 @@ function useTapToTalk(options = {}) {
|
|
|
5367
6012
|
* a page calls when its own run settles, which is exactly when the list is
|
|
5368
6013
|
* stale.
|
|
5369
6014
|
*/
|
|
5370
|
-
/**
|
|
5371
|
-
* Read a workflow's recent runs.
|
|
5372
|
-
*
|
|
5373
|
-
* @typeParam R - The workflow's output type, so a completed run's `output` is
|
|
5374
|
-
* typed rather than `unknown`. Derive it with `WorkflowOutputOf`.
|
|
5375
|
-
*
|
|
5376
|
-
* @example
|
|
5377
|
-
* ```tsx
|
|
5378
|
-
* import { useWorkflowRuns } from "@alexkroman1/aai-ui";
|
|
5379
|
-
*
|
|
5380
|
-
* function History() {
|
|
5381
|
-
* const { runs } = useWorkflowRuns("transcribe", { limit: 10 });
|
|
5382
|
-
* return <ul>{runs.map((run) => <li key={run.runId}>{run.status}</li>)}</ul>;
|
|
5383
|
-
* }
|
|
5384
|
-
* ```
|
|
5385
|
-
*
|
|
5386
|
-
* @public
|
|
5387
|
-
*/
|
|
5388
6015
|
function useWorkflowRuns(workflow, options = {}) {
|
|
5389
6016
|
const { api, limit, key, skip = false } = options;
|
|
5390
6017
|
const [runs, setRuns] = useState([]);
|
|
@@ -5521,94 +6148,53 @@ function useWorkflowRuns(workflow, options = {}) {
|
|
|
5521
6148
|
* `stream.ts`'s idle bound (five minutes of no new bytes) is what decides that a
|
|
5522
6149
|
* pause has become an abandonment.
|
|
5523
6150
|
*/
|
|
5524
|
-
/**
|
|
5525
|
-
* Start a workflow run and stream a file into it while it works.
|
|
5526
|
-
*
|
|
5527
|
-
* The workflow declares which input property carries the upload
|
|
5528
|
-
* (`workflow({ uploads: ["recording"] })`) — the same declaration
|
|
5529
|
-
* `useWorkflowSubmit` reads, because what the property carries is an upload id
|
|
5530
|
-
* either way. What differs is only WHEN the id becomes valid.
|
|
5531
|
-
*
|
|
5532
|
-
* @typeParam R - The workflow's output type, which is what makes
|
|
5533
|
-
* `run.status === "completed"` narrow to a typed `run.output`. Derive it with
|
|
5534
|
-
* `WorkflowOutputOf<typeof myWorkflow>`.
|
|
5535
|
-
*
|
|
5536
|
-
* @example
|
|
5537
|
-
* ```tsx no-check
|
|
5538
|
-
* const { submit, run, upload, pending, error } = useWorkflowStream("transcribe");
|
|
5539
|
-
*
|
|
5540
|
-
* <Form onSubmit={(values) => submit(values)} error={error}>
|
|
5541
|
-
* <WorkflowFields workflow="transcribe" />
|
|
5542
|
-
* <UploadProgressBar upload={upload} />
|
|
5543
|
-
* <SubmitButton pending={pending}>Transcribe</SubmitButton>
|
|
5544
|
-
* </Form>
|
|
5545
|
-
* ```
|
|
5546
|
-
*
|
|
5547
|
-
* @public
|
|
5548
|
-
*/
|
|
5549
6151
|
function useWorkflowStream(workflow, options = {}) {
|
|
5550
6152
|
const { api, key, intervalMs, parallel } = options;
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
const { runId, actions } = form;
|
|
5554
|
-
const tracked = useWorkflowRun(runId, omitUndefined({
|
|
6153
|
+
return useSubmission({
|
|
6154
|
+
getClient: useWorkflowApiRef(api),
|
|
5555
6155
|
api,
|
|
5556
|
-
intervalMs
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
|
|
5580
|
-
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
return {
|
|
5599
|
-
submit,
|
|
5600
|
-
submitForm: submit,
|
|
5601
|
-
reset: actions.reset,
|
|
5602
|
-
wake,
|
|
5603
|
-
cancel,
|
|
5604
|
-
pauseUpload: actions.pauseUpload,
|
|
5605
|
-
resumeUpload: actions.resumeUpload,
|
|
5606
|
-
run: tracked.run,
|
|
5607
|
-
startedHere: form.startedHere,
|
|
5608
|
-
pending: form.busy || tracked.polling,
|
|
5609
|
-
upload: form.upload,
|
|
5610
|
-
error: form.startError ?? tracked.error
|
|
5611
|
-
};
|
|
6156
|
+
intervalMs,
|
|
6157
|
+
scope: "none",
|
|
6158
|
+
begin: useCallback((input, client) => {
|
|
6159
|
+
const gate = createUploadGate();
|
|
6160
|
+
return {
|
|
6161
|
+
gate,
|
|
6162
|
+
work: async ({ progress, started: adopt }) => {
|
|
6163
|
+
let started;
|
|
6164
|
+
try {
|
|
6165
|
+
const id = randomUploadId();
|
|
6166
|
+
const begun = await beginRun({
|
|
6167
|
+
client,
|
|
6168
|
+
workflow,
|
|
6169
|
+
input,
|
|
6170
|
+
id,
|
|
6171
|
+
...omitUndefined({ key })
|
|
6172
|
+
});
|
|
6173
|
+
started = begun.runId;
|
|
6174
|
+
const chosen = begun.file;
|
|
6175
|
+
adopt(started);
|
|
6176
|
+
if (!chosen) return;
|
|
6177
|
+
await streamFile({
|
|
6178
|
+
client,
|
|
6179
|
+
gate,
|
|
6180
|
+
id,
|
|
6181
|
+
file: chosen,
|
|
6182
|
+
parallel,
|
|
6183
|
+
report: coalesceUploadReports(progress)
|
|
6184
|
+
});
|
|
6185
|
+
await client.wake(started).catch(() => void 0);
|
|
6186
|
+
} catch (err) {
|
|
6187
|
+
if (started) await client.cancel(started).catch(() => void 0);
|
|
6188
|
+
throw err;
|
|
6189
|
+
}
|
|
6190
|
+
}
|
|
6191
|
+
};
|
|
6192
|
+
}, [
|
|
6193
|
+
workflow,
|
|
6194
|
+
key,
|
|
6195
|
+
parallel
|
|
6196
|
+
])
|
|
6197
|
+
});
|
|
5612
6198
|
}
|
|
5613
6199
|
/**
|
|
5614
6200
|
* Read the declaration, substitute the id, and start the run.
|
|
@@ -5703,4 +6289,4 @@ function fileAt(input, field) {
|
|
|
5703
6289
|
return filesOf(input[field])[0];
|
|
5704
6290
|
}
|
|
5705
6291
|
//#endregion
|
|
5706
|
-
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, browserClientId, createBrowserSession, createInbox, createLinkedClient, createStoredValue, createWorkflowApi, errorMessage, fetchClientConfig, fieldKindFor, inboxEventToItem, isFileValue, isTerminal, mountClient, mountPage, phoneE164, routeFetch, selectAgentState, useAgentState, useClientId, useClientRuns, useClientTool, useConversation, useConversationLog, useCopy, useDownloadUrl, useEvent, useFlash, useInbox, usePushToTalk, useRoute, useRouteMutation, useRunKey, useSession, useSessionActions, useSessionControls, useSessionError, useSessionId, useSessionSelector, useSessionStatus, useStoredValue, useTapToTalk, useTheme, useToolCallStart, useToolResult, useUserTranscript, useWorkflowProgress, useWorkflowRun, useWorkflowRuns, useWorkflowStream, useWorkflowSubmit, useWorkflows };
|
|
6292
|
+
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, appendDictation, browserClientId, createBrowserSession, createInbox, createLinkedClient, createStoredValue, createWorkflowApi, dictationTranscriber, errorMessage, fetchClientConfig, fieldKindFor, inboxEventToItem, isFileValue, isTerminal, mountClient, mountPage, phoneE164, routeFetch, selectAgentState, useAgentState, useClientId, useClientRuns, useClientTool, useConversation, useConversationLog, useCopy, useDictation, useDownloadUrl, useEvent, useFlash, useInbox, usePushToTalk, useRoute, useRouteMutation, useRunKey, useSession, useSessionActions, useSessionControls, useSessionError, useSessionId, useSessionSelector, useSessionStatus, useStoredValue, useTapToTalk, useTheme, useToolCallStart, useToolResult, useUserTranscript, useWorkflowProgress, useWorkflowRun, useWorkflowRuns, useWorkflowStream, useWorkflowSubmit, useWorkflows };
|