@alexkroman1/aai-ui 5.13.2 → 6.1.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 +2 -1
- package/dist/_repeat-until.d.ts +30 -0
- package/dist/_sse.d.ts +56 -0
- package/dist/_workflow-api-ref.d.ts +37 -0
- package/dist/audio.js +26 -26
- package/dist/{chat-view-DadZOvJO.js → chat-view-CK61bWWx.js} +4 -3
- package/dist/components/_form-values.d.ts +19 -0
- package/dist/components/auto-scroll.d.ts +63 -0
- package/dist/components/chat-view.js +1 -1
- package/dist/components/controls.js +1 -1
- package/dist/components/form-types.d.ts +67 -0
- package/dist/components/form.d.ts +138 -0
- package/dist/components/message-list.js +1 -1
- package/dist/components/start-screen.js +1 -1
- package/dist/components/tool-call-block.js +1 -1
- package/dist/components/workflow-fields.d.ts +57 -0
- package/dist/components/workflow-progress.d.ts +55 -0
- package/dist/default-client/assets/audio-fO7SVU64.js +1 -0
- package/dist/default-client/assets/{capture-processor-CWRLCPS3.js → capture-processor-Dmc-KEpb.js} +4 -4
- package/dist/default-client/assets/client-audio-constants-Ck0IJO4c.js +1 -0
- package/dist/default-client/assets/index-4u908fff.js +293 -0
- package/dist/default-client/assets/index-CDugAuLK.css +2 -0
- package/dist/default-client/assets/{playback-processor-SKKE9qu2.js → playback-processor-DUsmALNH.js} +10 -3
- package/dist/default-client/index.html +3 -2
- package/dist/define-client.d.ts +40 -1
- package/dist/define-client.js +61 -19
- package/dist/hooks.d.ts +30 -0
- package/dist/hooks.js +4 -26
- package/dist/index.d.ts +11 -0
- package/dist/index.js +1597 -7
- package/dist/{message-list-YdLocGoT.js → message-list-BwA3rdPi.js} +105 -27
- package/dist/page.d.ts +88 -0
- package/dist/{session-core-BA8H3qtF.js → session-core-ClKdVgRU.js} +245 -112
- package/dist/session-core-dial.d.ts +38 -0
- package/dist/session-core-handshake.d.ts +16 -1
- package/dist/session-core-messages.d.ts +2 -2
- package/dist/session-core-reconnect.d.ts +2 -7
- package/dist/session-core.js +1 -1
- package/dist/session-resume-store.d.ts +43 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/dist/use-user-transcript.d.ts +70 -0
- package/dist/use-workflow-form.d.ts +136 -0
- package/dist/use-workflow-progress.d.ts +100 -0
- package/dist/use-workflow-run.d.ts +56 -0
- package/dist/use-workflow-runs.d.ts +71 -0
- package/dist/workflow-client.d.ts +97 -0
- package/dist/workflow-events.d.ts +39 -0
- package/dist/worklets/playback-processor.d.ts +1 -1
- package/dist/worklets/playback-processor.js +8 -1
- package/package.json +9 -8
- package/dist/default-client/assets/audio-BqyrSHNq.js +0 -1
- package/dist/default-client/assets/index-Ctjrde3-.css +0 -2
- package/dist/default-client/assets/index-DfVI-qZ8.js +0 -293
- package/dist/{aai-logo-B8lDmsut.js → aai-logo-9xRBGVFl.js} +1 -1
- package/dist/{controls-DzQEKq9c.js → controls-Cy_YVfsa.js} +1 -1
- package/dist/{tool-call-block-CAscLGFy.js → tool-call-block-CrLN7xlI.js} +1 -1
package/dist/index.js
CHANGED
|
@@ -1,13 +1,1603 @@
|
|
|
1
|
-
import { i as loadClientConfig, n as buildAgentUrl, r as fetchClientConfig, t as createSessionCore } from "./session-core-
|
|
1
|
+
import { i as loadClientConfig, n as buildAgentUrl, r as fetchClientConfig, t as createSessionCore } from "./session-core-ClKdVgRU.js";
|
|
2
|
+
import { n as Markdown, r as AutoScroll, t as MessageList } from "./message-list-BwA3rdPi.js";
|
|
2
3
|
import { SessionProvider, ThemeProvider, useSession, useSessionSelector, useTheme } from "./context.js";
|
|
3
4
|
import { Button } from "./components/button.js";
|
|
4
|
-
import { t as ChatView } from "./chat-view-
|
|
5
|
-
import {
|
|
6
|
-
import { n as
|
|
7
|
-
import { n as ToolConfigContext, r as ToolCallRow } from "./tool-call-block-
|
|
5
|
+
import { t as ChatView } from "./chat-view-CK61bWWx.js";
|
|
6
|
+
import { t as pageBaseUrl } from "./_utils-CsqbIVGI.js";
|
|
7
|
+
import { i as UiUrlChip, n as ApiUrlChip, r as SessionUrlChips, t as Controls } from "./controls-Cy_YVfsa.js";
|
|
8
|
+
import { n as ToolConfigContext, r as ToolCallRow } from "./tool-call-block-CrLN7xlI.js";
|
|
8
9
|
import { SidebarLayout } from "./components/sidebar-layout.js";
|
|
9
10
|
import { StartScreen } from "./components/start-screen.js";
|
|
10
11
|
import { VOICE_CAPTURE_CONSTRAINTS } from "./types.js";
|
|
11
|
-
import { client } from "./define-client.js";
|
|
12
|
+
import { client, mountRoot, resolveContainer } from "./define-client.js";
|
|
12
13
|
import { useAgentState, useEvent, useToolCallStart, useToolResult } from "./hooks.js";
|
|
13
|
-
|
|
14
|
+
import clsx from "clsx";
|
|
15
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
16
|
+
import { createElement, useCallback, useEffect, useId, useRef, useState } from "react";
|
|
17
|
+
import { errorMessage, isTerminal, isTerminal as isTerminal$1 } from "@alexkroman1/aai";
|
|
18
|
+
import { isRecord, omitUndefined, safeJsonParse as safeJsonParse$1 } from "@alexkroman1/aai/utils";
|
|
19
|
+
import { createWorkflowApiClient } from "@alexkroman1/aai/workflow-api";
|
|
20
|
+
import { createParser } from "eventsource-parser";
|
|
21
|
+
import { createEpoch } from "@alexkroman1/aai/internal";
|
|
22
|
+
//#region components/_form-values.ts
|
|
23
|
+
/**
|
|
24
|
+
* Read one `<form>`'s named controls into a plain object.
|
|
25
|
+
*
|
|
26
|
+
* Exported for tests and for a caller doing its own submit handling.
|
|
27
|
+
*
|
|
28
|
+
* @internal
|
|
29
|
+
*/
|
|
30
|
+
async function collectValues(form) {
|
|
31
|
+
const values = {};
|
|
32
|
+
for (const element of Array.from(form.elements)) if (element instanceof HTMLInputElement) await readInput(element, values);
|
|
33
|
+
else if ((element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) && element.name !== "" && !element.disabled) values[element.name] = element instanceof HTMLSelectElement ? readSelect(element) : element.value;
|
|
34
|
+
return values;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* One `<select>`, by arity.
|
|
38
|
+
*
|
|
39
|
+
* `HTMLSelectElement.value` is the FIRST selected option and nothing more, so a
|
|
40
|
+
* `<SelectField multiple>` — which type-checks, since the props extend
|
|
41
|
+
* `SelectHTMLAttributes` — contributed one string where its schema is waiting
|
|
42
|
+
* for a list. `selectedOptions` is the whole of what the user picked; nothing
|
|
43
|
+
* selected contributes `[]`, which is the honest answer for a control that is
|
|
44
|
+
* present and empty rather than one that was left blank.
|
|
45
|
+
*/
|
|
46
|
+
function readSelect(select) {
|
|
47
|
+
if (!select.multiple) return select.value;
|
|
48
|
+
return Array.from(select.selectedOptions, (option) => option.value);
|
|
49
|
+
}
|
|
50
|
+
/** One `<input>`, by type. */
|
|
51
|
+
async function readInput(input, values) {
|
|
52
|
+
if (input.name === "" || input.disabled) return;
|
|
53
|
+
switch (input.type) {
|
|
54
|
+
case "checkbox":
|
|
55
|
+
values[input.name] = input.checked;
|
|
56
|
+
return;
|
|
57
|
+
case "radio":
|
|
58
|
+
if (input.checked) values[input.name] = input.value;
|
|
59
|
+
return;
|
|
60
|
+
case "number":
|
|
61
|
+
case "range": {
|
|
62
|
+
if (input.value === "") return;
|
|
63
|
+
const value = input.valueAsNumber;
|
|
64
|
+
values[input.name] = Number.isNaN(value) ? input.value : value;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
case "file": {
|
|
68
|
+
const files = Array.from(input.files ?? []);
|
|
69
|
+
if (files.length === 0) return;
|
|
70
|
+
const chosen = await readFiles(files, readMode(input.dataset.aaiRead));
|
|
71
|
+
values[input.name] = input.multiple ? chosen : chosen[0];
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
default: values[input.name] = input.value;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The chosen files, as the field's `read` mode says to contribute them.
|
|
79
|
+
*
|
|
80
|
+
* An UPLOAD field contributes each `File` UNREAD. Reading it here would mean
|
|
81
|
+
* holding a 200 MB recording in memory to describe it, and the thing that needs
|
|
82
|
+
* the bytes is the upload request `useWorkflowSubmit` makes — which streams the
|
|
83
|
+
* same `File` object straight to the agent.
|
|
84
|
+
*/
|
|
85
|
+
function readFiles(files, read) {
|
|
86
|
+
if (read === "upload") return Promise.resolve([...files]);
|
|
87
|
+
return Promise.all(files.map((file) => describeFile(file, read)));
|
|
88
|
+
}
|
|
89
|
+
/** The `data-aai-read` attribute as a {@link FileRead}, defaulting to `"none"`. */
|
|
90
|
+
function readMode(raw) {
|
|
91
|
+
return raw === "text" || raw === "dataUrl" || raw === "upload" ? raw : "none";
|
|
92
|
+
}
|
|
93
|
+
/** One chosen file as a {@link FileValue}. */
|
|
94
|
+
async function describeFile(file, read) {
|
|
95
|
+
const described = {
|
|
96
|
+
name: file.name,
|
|
97
|
+
size: file.size,
|
|
98
|
+
type: file.type,
|
|
99
|
+
lastModified: file.lastModified
|
|
100
|
+
};
|
|
101
|
+
if (read === "none") return described;
|
|
102
|
+
return {
|
|
103
|
+
...described,
|
|
104
|
+
content: read === "text" ? await file.text() : await dataUrl(file)
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/** A file as a `data:` URL. */
|
|
108
|
+
function dataUrl(file) {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const reader = new FileReader();
|
|
111
|
+
reader.onerror = () => reject(reader.error ?? /* @__PURE__ */ new Error(`Could not read ${file.name}`));
|
|
112
|
+
reader.onload = () => resolve(String(reader.result));
|
|
113
|
+
reader.readAsDataURL(file);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region components/form.tsx
|
|
118
|
+
/** @jsxImportSource react */
|
|
119
|
+
/**
|
|
120
|
+
* Simple forms, for the pages that are not conversations.
|
|
121
|
+
*
|
|
122
|
+
* A workflow app's front door is a form: name a recording, upload a file, press
|
|
123
|
+
* submit. Nothing here knew how to render one — the components in this package
|
|
124
|
+
* are all about a live session (a transcript, a mic button, a tool-call row) —
|
|
125
|
+
* so every such page started by hand-rolling labels, inputs, a submit button and
|
|
126
|
+
* the value collection between them, and did it differently each time.
|
|
127
|
+
*
|
|
128
|
+
* ## Values come off the DOM, not out of React state
|
|
129
|
+
*
|
|
130
|
+
* {@link Form} reads its own `<form>` element on submit and builds one plain
|
|
131
|
+
* object from the named controls. That is what makes a field here nothing more
|
|
132
|
+
* than a styled `<input>`: no registration, no controlled-component ceremony,
|
|
133
|
+
* and a plain `<input name="x">` a caller writes themselves works exactly like
|
|
134
|
+
* the ones below.
|
|
135
|
+
*
|
|
136
|
+
* It also means the values are TYPED rather than all-strings, which a
|
|
137
|
+
* `new FormData(form)` cannot give: a number field yields a number, a checkbox a
|
|
138
|
+
* boolean, an empty optional field nothing at all. That matters because these
|
|
139
|
+
* values go straight into a workflow's input, where a zod schema is waiting —
|
|
140
|
+
* `"3"` against `z.number()` is a rejected run, and the browser is the only
|
|
141
|
+
* place that still knows the field was `type="number"`.
|
|
142
|
+
*
|
|
143
|
+
* ## A file field either describes a file or uploads it
|
|
144
|
+
*
|
|
145
|
+
* See {@link FileField}. A workflow input is journaled and replayed on every
|
|
146
|
+
* resume, so file BYTES do not belong in it — `upload` is the field that sends
|
|
147
|
+
* them somewhere a step can read them and contributes the handle instead.
|
|
148
|
+
*/
|
|
149
|
+
/**
|
|
150
|
+
* A form that hands its values to `onSubmit` as one object.
|
|
151
|
+
*
|
|
152
|
+
* Native validation still applies — a `required` field blocks the submit and the
|
|
153
|
+
* browser says so, which is better than anything this could render.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```tsx
|
|
157
|
+
* import { Form, SubmitButton, TextField } from "@alexkroman1/aai-ui";
|
|
158
|
+
*
|
|
159
|
+
* function NameForm() {
|
|
160
|
+
* return (
|
|
161
|
+
* <Form onSubmit={(values) => console.log(values.topic)}>
|
|
162
|
+
* <TextField name="topic" label="Topic" required />
|
|
163
|
+
* <SubmitButton>Start</SubmitButton>
|
|
164
|
+
* </Form>
|
|
165
|
+
* );
|
|
166
|
+
* }
|
|
167
|
+
* ```
|
|
168
|
+
*
|
|
169
|
+
* @public
|
|
170
|
+
*/
|
|
171
|
+
function Form({ onSubmit, error, children, className, ...rest }) {
|
|
172
|
+
const theme = useTheme();
|
|
173
|
+
const [busy, setBusy] = useState(false);
|
|
174
|
+
return /* @__PURE__ */ jsxs("form", {
|
|
175
|
+
onSubmit: useCallback((event) => {
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
if (busy) return;
|
|
178
|
+
const form = event.currentTarget;
|
|
179
|
+
setBusy(true);
|
|
180
|
+
(async () => {
|
|
181
|
+
try {
|
|
182
|
+
await onSubmit(await collectValues(form));
|
|
183
|
+
} finally {
|
|
184
|
+
setBusy(false);
|
|
185
|
+
}
|
|
186
|
+
})();
|
|
187
|
+
}, [busy, onSubmit]),
|
|
188
|
+
className: clsx("flex flex-col gap-5 font-aai", className),
|
|
189
|
+
...rest,
|
|
190
|
+
children: [/* @__PURE__ */ jsx("fieldset", {
|
|
191
|
+
disabled: busy,
|
|
192
|
+
className: "contents",
|
|
193
|
+
children
|
|
194
|
+
}), error !== void 0 && error !== "" && /* @__PURE__ */ jsx("p", {
|
|
195
|
+
role: "alert",
|
|
196
|
+
className: "text-sm",
|
|
197
|
+
style: { color: theme.primary },
|
|
198
|
+
children: error
|
|
199
|
+
})]
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Label + control + hint, in the layout every field here uses.
|
|
204
|
+
*
|
|
205
|
+
* Exported so a caller's own control gets the same shell rather than an
|
|
206
|
+
* approximation of it.
|
|
207
|
+
*
|
|
208
|
+
* @public
|
|
209
|
+
*/
|
|
210
|
+
function Field({ label, hint, htmlFor, className, children }) {
|
|
211
|
+
const theme = useTheme();
|
|
212
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
213
|
+
className: clsx("flex flex-col gap-1.5", className),
|
|
214
|
+
children: [
|
|
215
|
+
label !== void 0 && /* @__PURE__ */ jsx("label", {
|
|
216
|
+
htmlFor,
|
|
217
|
+
className: "text-[11px] font-medium uppercase tracking-[1.2px]",
|
|
218
|
+
style: { color: theme.text },
|
|
219
|
+
children: label
|
|
220
|
+
}),
|
|
221
|
+
children,
|
|
222
|
+
hint !== void 0 && /* @__PURE__ */ jsx("p", {
|
|
223
|
+
className: "text-xs opacity-60",
|
|
224
|
+
style: { color: theme.text },
|
|
225
|
+
children: hint
|
|
226
|
+
})
|
|
227
|
+
]
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/** The shared control styling — one place, so the fields cannot drift apart. */
|
|
231
|
+
function useControlProps() {
|
|
232
|
+
const theme = useTheme();
|
|
233
|
+
return {
|
|
234
|
+
className: clsx("w-full rounded-aai border px-3 py-2 text-sm font-aai", "outline-none focus-visible:[outline:2px_solid] focus-visible:[outline-offset:2px]", "disabled:cursor-not-allowed disabled:opacity-50"),
|
|
235
|
+
style: {
|
|
236
|
+
background: theme.surface,
|
|
237
|
+
color: theme.text,
|
|
238
|
+
borderColor: theme.border,
|
|
239
|
+
outlineColor: theme.primary
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* The control styling for a file input.
|
|
245
|
+
*
|
|
246
|
+
* A file input is the one control whose BUTTON the browser draws, and every
|
|
247
|
+
* engine draws it differently: left to the user agent it inherits the field's
|
|
248
|
+
* own colours and can come out as invisible text on the surface it sits on —
|
|
249
|
+
* which is what "the Choose file button doesn't display" is. So the button is
|
|
250
|
+
* styled explicitly through `::file-selector-button` (Tailwind's `file:`
|
|
251
|
+
* variant) in the theme's own colours, and the field's vertical padding is
|
|
252
|
+
* reduced to the button's, since the button is what sets the row's height.
|
|
253
|
+
*
|
|
254
|
+
* The colours reach the variant as CSS CUSTOM PROPERTIES, because a Tailwind
|
|
255
|
+
* class cannot read a JavaScript theme object and a pseudo-element cannot be
|
|
256
|
+
* reached by a React `style` prop.
|
|
257
|
+
*/
|
|
258
|
+
function useFileControlProps() {
|
|
259
|
+
const theme = useTheme();
|
|
260
|
+
const control = useControlProps();
|
|
261
|
+
return {
|
|
262
|
+
className: clsx(control.className, "cursor-pointer py-1.5 pl-1.5", "file:mr-3 file:cursor-pointer file:rounded-aai file:border-0 file:px-3 file:py-1.5", "file:text-sm file:font-medium file:font-aai", "file:[background:var(--aai-file-button-bg)] file:[color:var(--aai-file-button-fg)]"),
|
|
263
|
+
style: {
|
|
264
|
+
...control.style,
|
|
265
|
+
"--aai-file-button-bg": theme.primary,
|
|
266
|
+
"--aai-file-button-fg": theme.surface
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* A single-line text input.
|
|
272
|
+
*
|
|
273
|
+
* @public
|
|
274
|
+
*/
|
|
275
|
+
function TextField({ name, label, hint, className, ...rest }) {
|
|
276
|
+
const id = useId();
|
|
277
|
+
return /* @__PURE__ */ jsx(Field, {
|
|
278
|
+
label,
|
|
279
|
+
hint,
|
|
280
|
+
htmlFor: id,
|
|
281
|
+
className,
|
|
282
|
+
children: /* @__PURE__ */ jsx("input", {
|
|
283
|
+
id,
|
|
284
|
+
name,
|
|
285
|
+
type: "text",
|
|
286
|
+
...useControlProps(),
|
|
287
|
+
...rest
|
|
288
|
+
})
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* A number input. Contributes a NUMBER to {@link FormValues}, or nothing when
|
|
293
|
+
* left empty.
|
|
294
|
+
*
|
|
295
|
+
* @public
|
|
296
|
+
*/
|
|
297
|
+
function NumberField({ name, label, hint, className, ...rest }) {
|
|
298
|
+
const id = useId();
|
|
299
|
+
return /* @__PURE__ */ jsx(Field, {
|
|
300
|
+
label,
|
|
301
|
+
hint,
|
|
302
|
+
htmlFor: id,
|
|
303
|
+
className,
|
|
304
|
+
children: /* @__PURE__ */ jsx("input", {
|
|
305
|
+
id,
|
|
306
|
+
name,
|
|
307
|
+
type: "number",
|
|
308
|
+
...useControlProps(),
|
|
309
|
+
...rest
|
|
310
|
+
})
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* A multi-line text input.
|
|
315
|
+
*
|
|
316
|
+
* @public
|
|
317
|
+
*/
|
|
318
|
+
function TextAreaField({ name, label, hint, className, rows = 4, ...rest }) {
|
|
319
|
+
const id = useId();
|
|
320
|
+
return /* @__PURE__ */ jsx(Field, {
|
|
321
|
+
label,
|
|
322
|
+
hint,
|
|
323
|
+
htmlFor: id,
|
|
324
|
+
className,
|
|
325
|
+
children: /* @__PURE__ */ jsx("textarea", {
|
|
326
|
+
id,
|
|
327
|
+
name,
|
|
328
|
+
rows,
|
|
329
|
+
...useControlProps(),
|
|
330
|
+
...rest
|
|
331
|
+
})
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* A dropdown. Pass `options`, or `children` for full control over the
|
|
336
|
+
* `<option>` elements.
|
|
337
|
+
*
|
|
338
|
+
* @public
|
|
339
|
+
*/
|
|
340
|
+
function SelectField({ name, label, hint, className, options, children, ...rest }) {
|
|
341
|
+
const id = useId();
|
|
342
|
+
return /* @__PURE__ */ jsx(Field, {
|
|
343
|
+
label,
|
|
344
|
+
hint,
|
|
345
|
+
htmlFor: id,
|
|
346
|
+
className,
|
|
347
|
+
children: /* @__PURE__ */ jsx("select", {
|
|
348
|
+
id,
|
|
349
|
+
name,
|
|
350
|
+
...useControlProps(),
|
|
351
|
+
...rest,
|
|
352
|
+
children: children ?? options?.map((option) => {
|
|
353
|
+
const value = typeof option === "string" ? option : option.value;
|
|
354
|
+
return /* @__PURE__ */ jsx("option", {
|
|
355
|
+
value,
|
|
356
|
+
children: typeof option === "string" ? option : option.label
|
|
357
|
+
}, value);
|
|
358
|
+
})
|
|
359
|
+
})
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* A checkbox. Contributes a BOOLEAN to {@link FormValues}.
|
|
364
|
+
*
|
|
365
|
+
* @public
|
|
366
|
+
*/
|
|
367
|
+
function CheckboxField({ name, label, hint, className, ...rest }) {
|
|
368
|
+
const id = useId();
|
|
369
|
+
const theme = useTheme();
|
|
370
|
+
return /* @__PURE__ */ jsx(Field, {
|
|
371
|
+
hint,
|
|
372
|
+
className,
|
|
373
|
+
children: /* @__PURE__ */ jsxs("label", {
|
|
374
|
+
htmlFor: id,
|
|
375
|
+
className: "flex items-center gap-2 text-sm",
|
|
376
|
+
style: { color: theme.text },
|
|
377
|
+
children: [/* @__PURE__ */ jsx("input", {
|
|
378
|
+
id,
|
|
379
|
+
name,
|
|
380
|
+
type: "checkbox",
|
|
381
|
+
style: { accentColor: theme.primary },
|
|
382
|
+
...rest
|
|
383
|
+
}), label]
|
|
384
|
+
})
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* A file picker. Contributes a {@link FileValue} (or an array, with `multiple`)
|
|
389
|
+
* to {@link FormValues} — or nothing when no file was chosen.
|
|
390
|
+
*
|
|
391
|
+
* **`upload` is what a workflow input wants.** A run's input is serialized into
|
|
392
|
+
* the run record and replayed from it on every resume, so a file's BYTES cannot
|
|
393
|
+
* travel in it. With `upload` the field contributes the `File` itself,
|
|
394
|
+
* `useWorkflowSubmit` stores it through `POST /workflows/uploads` before
|
|
395
|
+
* starting the run, and the input carries the upload id — which a step reads
|
|
396
|
+
* windows of with `readUpload`. Declaring the property in the workflow's
|
|
397
|
+
* `uploads` list makes `<WorkflowFields>` render exactly this, so a declared
|
|
398
|
+
* form needs no file markup at all.
|
|
399
|
+
*
|
|
400
|
+
* **Without it the field describes the file and does not read it.** `read`
|
|
401
|
+
* exists for the cases where the bytes really are small and really are the
|
|
402
|
+
* input — a CSV of ids, a config — and the size is the author's to check.
|
|
403
|
+
*
|
|
404
|
+
* @public
|
|
405
|
+
*/
|
|
406
|
+
function FileField({ name, label, hint, className, read = "none", upload = false, ...rest }) {
|
|
407
|
+
const id = useId();
|
|
408
|
+
const control = useFileControlProps();
|
|
409
|
+
return /* @__PURE__ */ jsx(Field, {
|
|
410
|
+
label,
|
|
411
|
+
hint,
|
|
412
|
+
htmlFor: id,
|
|
413
|
+
className,
|
|
414
|
+
children: /* @__PURE__ */ jsx("input", {
|
|
415
|
+
id,
|
|
416
|
+
name,
|
|
417
|
+
type: "file",
|
|
418
|
+
"data-aai-read": upload ? "upload" : read,
|
|
419
|
+
...control,
|
|
420
|
+
...rest
|
|
421
|
+
})
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* The form's submit button, disabled and relabelled while a submit is in
|
|
426
|
+
* flight.
|
|
427
|
+
*
|
|
428
|
+
* @public
|
|
429
|
+
*/
|
|
430
|
+
function SubmitButton({ children, pending = false, pendingLabel = "Working…", size, className }) {
|
|
431
|
+
return /* @__PURE__ */ jsx(Button, {
|
|
432
|
+
type: "submit",
|
|
433
|
+
disabled: pending,
|
|
434
|
+
...omitUndefined({
|
|
435
|
+
size,
|
|
436
|
+
className
|
|
437
|
+
}),
|
|
438
|
+
children: pending ? pendingLabel : children
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
//#endregion
|
|
442
|
+
//#region workflow-client.ts
|
|
443
|
+
/**
|
|
444
|
+
* Create a workflow API client aimed at the agent serving this page.
|
|
445
|
+
*
|
|
446
|
+
* Hoist it out of the component that uses it. `useWorkflowRun` holds the client
|
|
447
|
+
* in a ref precisely so a fresh object per render does not restart its watch,
|
|
448
|
+
* but a client built in render is still a new `fetch` closure every time and
|
|
449
|
+
* reads as though it were free.
|
|
450
|
+
*
|
|
451
|
+
* @public
|
|
452
|
+
*/
|
|
453
|
+
function createWorkflowApi(opts = {}) {
|
|
454
|
+
return createWorkflowApiClient({
|
|
455
|
+
baseUrl: opts.baseUrl ?? pageBaseUrl(),
|
|
456
|
+
...omitUndefined({ token: opts.token })
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
//#endregion
|
|
460
|
+
//#region _workflow-api-ref.ts
|
|
461
|
+
/**
|
|
462
|
+
* The client preamble every workflow hook needs, once.
|
|
463
|
+
*
|
|
464
|
+
* Five hooks (`useWorkflowRun`, `useWorkflowProgress`, `useWorkflowRuns`,
|
|
465
|
+
* `useWorkflows`, `useWorkflowSubmit`) opened with the same two refs and the
|
|
466
|
+
* same two paragraphs explaining them, and both halves are load-bearing rather
|
|
467
|
+
* than stylistic — which is exactly why they should not be re-derived per hook:
|
|
468
|
+
*
|
|
469
|
+
* - **The caller's client lives in a REF, never in an effect's dependency
|
|
470
|
+
* array.** The natural call site is
|
|
471
|
+
* `useWorkflowRun(id, { api: createWorkflowApi() })`, which passes a NEW
|
|
472
|
+
* object every render; as a dependency that tears the effect down and
|
|
473
|
+
* restarts it on each one, and because a restart clears state and re-renders,
|
|
474
|
+
* it schedules the next. The result is an unbounded request loop against the
|
|
475
|
+
* agent — on the platform, against the BROKER — with `error` wiped before
|
|
476
|
+
* anything can read it, presenting as "the page polls forever" rather than as
|
|
477
|
+
* a mistake at the call site.
|
|
478
|
+
* - **The no-client default is built lazily and ONCE.** As a render-time
|
|
479
|
+
* default (`api ?? createWorkflowApi()`) it is a fresh object per render,
|
|
480
|
+
* which is the same hazard one layer down; built inside an effect it is a
|
|
481
|
+
* fresh object per watch.
|
|
482
|
+
*
|
|
483
|
+
* The returned getter is stable for the life of the component and reads the ref
|
|
484
|
+
* on every call, so a caller that SWAPS clients mid-watch — a token arriving
|
|
485
|
+
* after login — is picked up by the next request without the watch restarting.
|
|
486
|
+
*/
|
|
487
|
+
/**
|
|
488
|
+
* Resolve the client a workflow hook should use, now.
|
|
489
|
+
*
|
|
490
|
+
* @param api - The caller's client, or undefined for one aimed at the page's
|
|
491
|
+
* own agent.
|
|
492
|
+
* @returns A stable getter. Call it per request, never once per watch.
|
|
493
|
+
*
|
|
494
|
+
* @internal
|
|
495
|
+
*/
|
|
496
|
+
function useWorkflowApiRef(api) {
|
|
497
|
+
const apiRef = useRef(api);
|
|
498
|
+
apiRef.current = api;
|
|
499
|
+
const fallbackRef = useRef(void 0);
|
|
500
|
+
return useCallback(() => {
|
|
501
|
+
const current = apiRef.current;
|
|
502
|
+
if (current) return current;
|
|
503
|
+
fallbackRef.current ??= createWorkflowApi();
|
|
504
|
+
return fallbackRef.current;
|
|
505
|
+
}, []);
|
|
506
|
+
}
|
|
507
|
+
//#endregion
|
|
508
|
+
//#region _repeat-until.ts
|
|
509
|
+
/**
|
|
510
|
+
* A bounded read, re-armed from the SETTLED read — the loop both workflow
|
|
511
|
+
* watchers are built out of.
|
|
512
|
+
*
|
|
513
|
+
* `pollUntilTerminal` (`use-workflow-run.ts`) and `readProgressUntilComplete`
|
|
514
|
+
* (`use-workflow-progress.ts`) each open a request, decide from its answer
|
|
515
|
+
* whether to come back, and stop when told to. What they had in common was not
|
|
516
|
+
* the decision — one reads a snapshot, the other drains an SSE body — but the
|
|
517
|
+
* scaffold around it, and that scaffold is where the two rules live:
|
|
518
|
+
*
|
|
519
|
+
* - **Re-armed from the settled read, never on an interval.** A slow response
|
|
520
|
+
* would otherwise stack overlapping requests on an agent that is already
|
|
521
|
+
* struggling, and on the platform every one of them BROKERS.
|
|
522
|
+
* - **Cancellation is a signal, not a flag.** The teardown has to reach the
|
|
523
|
+
* in-flight request too — an abandoned progress read otherwise keeps pulling
|
|
524
|
+
* chunks out of a run for a page that has navigated away — so `step` is
|
|
525
|
+
* handed the signal rather than being trusted to check a boolean.
|
|
526
|
+
*/
|
|
527
|
+
/**
|
|
528
|
+
* Call `step` until it reports it is finished, or until the returned stop
|
|
529
|
+
* function is called.
|
|
530
|
+
*
|
|
531
|
+
* `step` resolves `true` when there is nothing left to come back for. It is
|
|
532
|
+
* responsible for its own failures: a rejection would leave the loop stopped
|
|
533
|
+
* with nobody told, so each caller decides whether its failure is terminal or
|
|
534
|
+
* just another reason to try again.
|
|
535
|
+
*
|
|
536
|
+
* @internal
|
|
537
|
+
*/
|
|
538
|
+
function repeatUntil(intervalMs, step) {
|
|
539
|
+
const controller = new AbortController();
|
|
540
|
+
let timer;
|
|
541
|
+
const tick = async () => {
|
|
542
|
+
if (await step(controller.signal) || controller.signal.aborted) return;
|
|
543
|
+
timer = setTimeout(() => void tick(), intervalMs);
|
|
544
|
+
};
|
|
545
|
+
tick();
|
|
546
|
+
return () => {
|
|
547
|
+
controller.abort();
|
|
548
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
//#endregion
|
|
552
|
+
//#region _sse.ts
|
|
553
|
+
/**
|
|
554
|
+
* The server-sent-event parser both workflow streams read through.
|
|
555
|
+
*
|
|
556
|
+
* Split out when the second stream arrived: `workflow-events.ts` watches a run's
|
|
557
|
+
* STATE and `use-workflow-progress.ts` reads what the run WROTE, and they parse
|
|
558
|
+
* the identical wire format. A second copy of a stream parser is the kind of
|
|
559
|
+
* duplication that goes wrong quietly — the two would drift on exactly the edges
|
|
560
|
+
* documented below, and the symptom is a page that silently stops updating.
|
|
561
|
+
*
|
|
562
|
+
* @internal
|
|
563
|
+
*/
|
|
564
|
+
/**
|
|
565
|
+
* Parse an SSE byte stream into frames, with `eventsource-parser`.
|
|
566
|
+
*
|
|
567
|
+
* The parser is `aai-studio-client`'s already (`src/api-events.ts`), and it is
|
|
568
|
+
* catalogued — plus a transitive dependency of `@ai-sdk/provider-utils`, so it
|
|
569
|
+
* is in this package's tree either way. Adopting it retired a hand-rolled line
|
|
570
|
+
* splitter justified on the subset in use being "small and fixed" — true of our
|
|
571
|
+
* own server, and not of what sits between it and the page:
|
|
572
|
+
*
|
|
573
|
+
* - It split on `"\n\n"` only. The spec permits `\n`, `\r\n` and `\r`, and a
|
|
574
|
+
* CRLF stream is `\r\n\r\n` — no two adjacent `\n`, so **not one frame ever
|
|
575
|
+
* parsed** and `pump` fell through to `"fallback"` on the clean end. Silently
|
|
576
|
+
* dropping to the poll is the exact cost the run-watch stream exists to avoid,
|
|
577
|
+
* and an intermediary re-terminating lines is not our choice to make.
|
|
578
|
+
* - `line.startsWith("event: ")` required the space the spec makes optional.
|
|
579
|
+
* - It kept only the LAST `data:` line rather than joining a multi-line one.
|
|
580
|
+
*
|
|
581
|
+
* Those three are what `workflow-events.test.ts` pins, and they are the three
|
|
582
|
+
* that DISCRIMINATE — checked by running the specs against the old parser.
|
|
583
|
+
* Comment frames and a leading BOM were already fine and are not credited here:
|
|
584
|
+
* a heartbeat has no `event:` line, so the old parser dropped it anyway, and
|
|
585
|
+
* `TextDecoder` strips the BOM before either parser sees a byte.
|
|
586
|
+
*
|
|
587
|
+
* Three properties of the parser this leans on. `feed` invokes `onEvent`
|
|
588
|
+
* SYNCHRONOUSLY for every complete event in the chunk, so a batch is collected
|
|
589
|
+
* per read and yielded in arrival order — the generator shape, and therefore
|
|
590
|
+
* every caller, is unchanged. An event with no `data:` line at all is not
|
|
591
|
+
* dispatched (also per spec); every frame these routes emit carries one, since
|
|
592
|
+
* `workflow-api-events.ts` and `workflow-api-stream.ts` write `event:` and
|
|
593
|
+
* `data:` together. And a chunk ending in a lone `\r` holds that byte back,
|
|
594
|
+
* because it may yet turn out to be the first half of a `\r\n` — so a CR-ONLY
|
|
595
|
+
* stream chunked per frame dispatches one frame behind, and its last frame not
|
|
596
|
+
* at all (it would need `reset({ consume: true })`, which would also consume a
|
|
597
|
+
* genuinely truncated frame as if it were whole). Nothing emits CR-only endings,
|
|
598
|
+
* and the outcome if anything did is the safe one for both readers: a stream
|
|
599
|
+
* that ends with no final frame is read as a dropped connection, which the run
|
|
600
|
+
* watch answers by falling back to the poll and the progress reader by
|
|
601
|
+
* re-opening.
|
|
602
|
+
*/
|
|
603
|
+
async function* sseFrames(body, signal) {
|
|
604
|
+
const reader = body.getReader();
|
|
605
|
+
const decoder = new TextDecoder();
|
|
606
|
+
let batch = [];
|
|
607
|
+
const parser = createParser({ onEvent: ({ event, data }) => {
|
|
608
|
+
if (event === void 0) return;
|
|
609
|
+
batch.push({
|
|
610
|
+
event,
|
|
611
|
+
data: safeJsonParse$1(data)
|
|
612
|
+
});
|
|
613
|
+
} });
|
|
614
|
+
try {
|
|
615
|
+
while (!signal.aborted) {
|
|
616
|
+
const { done, value } = await reader.read();
|
|
617
|
+
if (done) return;
|
|
618
|
+
parser.feed(decoder.decode(value, { stream: true }));
|
|
619
|
+
if (batch.length === 0) continue;
|
|
620
|
+
const frames = batch;
|
|
621
|
+
batch = [];
|
|
622
|
+
for (const frame of frames) yield frame;
|
|
623
|
+
}
|
|
624
|
+
} finally {
|
|
625
|
+
reader.cancel().catch(() => void 0);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
//#endregion
|
|
629
|
+
//#region workflow-events.ts
|
|
630
|
+
/**
|
|
631
|
+
* Watching a run over server-sent events — the PUSH half of `useWorkflowRun`.
|
|
632
|
+
*
|
|
633
|
+
* Its own module because the seam is clean: everything in `workflow-client.ts`
|
|
634
|
+
* is request/response shaping plus the poll, and this is one long-lived stream
|
|
635
|
+
* and the SSE parser it needs.
|
|
636
|
+
*
|
|
637
|
+
* @internal
|
|
638
|
+
*/
|
|
639
|
+
/**
|
|
640
|
+
* Watch a run over SSE, falling back to the caller's poll on any failure.
|
|
641
|
+
*
|
|
642
|
+
* The poll stays the fallback rather than being replaced, and that is the whole
|
|
643
|
+
* shape of this: a stream is an optimisation over a mechanism that already
|
|
644
|
+
* works, so every way it can fail — an older agent with no `/events` route, a
|
|
645
|
+
* proxy that buffers, a network that drops it — has to degrade to the thing that
|
|
646
|
+
* does. What it buys is real, though: on the platform every polled read BROKERS,
|
|
647
|
+
* so N open tabs at `DEFAULT_WORKFLOW_POLL_MS` is N/2 brokered requests a
|
|
648
|
+
* second, each able to boot a sandbox. One stream per tab replaces all of it.
|
|
649
|
+
*
|
|
650
|
+
* `EventSource` is not used, for two reasons that both matter here: it cannot
|
|
651
|
+
* send an `Authorization` header (an agent with `AAI_WORKFLOW_API_TOKEN` set
|
|
652
|
+
* would be unreachable), and it reconnects on its own schedule, which would
|
|
653
|
+
* fight the caller's. A `fetch` stream gives both back.
|
|
654
|
+
*
|
|
655
|
+
* Returns a stop function. `onFallback` is called at most once, when this stream
|
|
656
|
+
* cannot be relied on and the poll should take over.
|
|
657
|
+
*/
|
|
658
|
+
function watchRunEvents(getClient, runId, onRun, onSettled, onFallback) {
|
|
659
|
+
const controller = new AbortController();
|
|
660
|
+
let handedOver = false;
|
|
661
|
+
const handOver = () => {
|
|
662
|
+
if (handedOver || controller.signal.aborted) return;
|
|
663
|
+
handedOver = true;
|
|
664
|
+
onFallback();
|
|
665
|
+
};
|
|
666
|
+
/**
|
|
667
|
+
* Consume the stream. Resolves `"settled"` when the run reached a state
|
|
668
|
+
* nothing will change, and `"fallback"` for every other ending — including a
|
|
669
|
+
* clean end with no final frame, which is a dropped connection.
|
|
670
|
+
*
|
|
671
|
+
* A named function rather than an inline IIFE so `watchRunEvents` stays under
|
|
672
|
+
* the cognitive-complexity cap, and so the two outcomes are a return value
|
|
673
|
+
* instead of two callbacks invoked from six places.
|
|
674
|
+
*/
|
|
675
|
+
const pump = async () => {
|
|
676
|
+
const res = await getClient().watch(runId, controller.signal);
|
|
677
|
+
if (!(res.ok && res.body)) return "fallback";
|
|
678
|
+
for await (const frame of sseFrames(res.body, controller.signal)) {
|
|
679
|
+
if (frame.event === "run" && frame.data) onRun(frame.data);
|
|
680
|
+
const outcome = endingFor(frame.event);
|
|
681
|
+
if (outcome) return outcome;
|
|
682
|
+
}
|
|
683
|
+
return "fallback";
|
|
684
|
+
};
|
|
685
|
+
pump().then((outcome) => outcome === "settled" ? onSettled() : handOver(), () => handOver());
|
|
686
|
+
return () => controller.abort();
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Does this frame END the stream, and does the run need watching afterwards?
|
|
690
|
+
*
|
|
691
|
+
* `done` and `missing` are both final and neither wants a reconnect: the run is
|
|
692
|
+
* terminal, or the id will never exist (a 404 is a stable answer — the world's
|
|
693
|
+
* record is durable). `idle` is the stream handing ITSELF back after its
|
|
694
|
+
* duration cap, so that one falls back to the poll. Anything else is not an
|
|
695
|
+
* ending.
|
|
696
|
+
*/
|
|
697
|
+
function endingFor(event) {
|
|
698
|
+
if (event === "done" || event === "missing") return "settled";
|
|
699
|
+
return event === "idle" ? "fallback" : void 0;
|
|
700
|
+
}
|
|
701
|
+
//#endregion
|
|
702
|
+
//#region use-workflow-run.ts
|
|
703
|
+
/**
|
|
704
|
+
* `useWorkflowRun` — watch one run until it settles.
|
|
705
|
+
*
|
|
706
|
+
* Split from `workflow-client.ts` on the seam that module's doc already draws:
|
|
707
|
+
* everything there is a REQUEST (one call, one answer, no React), and everything
|
|
708
|
+
* here is the loop that keeps asking. They are read for different reasons — the
|
|
709
|
+
* client is what a script or a `curl` equivalent needs, this is what a page needs
|
|
710
|
+
* — and only this half imports React.
|
|
711
|
+
*
|
|
712
|
+
* `workflow-events.ts` sits under it as the streaming fast path, and
|
|
713
|
+
* `use-workflow-form.ts` above it as the form-shaped caller.
|
|
714
|
+
*/
|
|
715
|
+
/** How often {@link useWorkflowRun} re-reads a live run when it has to poll. */
|
|
716
|
+
const DEFAULT_WORKFLOW_POLL_MS = 2e3;
|
|
717
|
+
/**
|
|
718
|
+
* Consecutive "no such run" reads {@link useWorkflowRun} tolerates before giving
|
|
719
|
+
* up on the id.
|
|
720
|
+
*
|
|
721
|
+
* Small on purpose: a 404 is a stable answer, so the budget exists only to
|
|
722
|
+
* absorb a first read that races the run's creation — not to keep hoping.
|
|
723
|
+
* Unbounded, a stale id polls (and, on the platform, BROKERS) for as long as the
|
|
724
|
+
* tab is open.
|
|
725
|
+
*/
|
|
726
|
+
const MAX_MISSING_READS = 3;
|
|
727
|
+
/**
|
|
728
|
+
* Poll `runId` until it is terminal, reporting each read. Returns a stop
|
|
729
|
+
* function.
|
|
730
|
+
*
|
|
731
|
+
* Module-level rather than inline in the hook below, so neither function carries
|
|
732
|
+
* the whole loop's branching — and so the loop can be read without React in the
|
|
733
|
+
* way.
|
|
734
|
+
*/
|
|
735
|
+
function pollUntilTerminal(getClient, runId, intervalMs, onRun, onError, onStopped) {
|
|
736
|
+
let missing = 0;
|
|
737
|
+
/**
|
|
738
|
+
* A read that came back empty. Resolves whether the loop should STOP.
|
|
739
|
+
*
|
|
740
|
+
* A 404 is a STABLE answer — a run the agent does not know about now will not
|
|
741
|
+
* appear later — and retrying it unbounded is how a stale id (one restored
|
|
742
|
+
* from `localStorage`, or one whose agent was redeployed onto a fresh
|
|
743
|
+
* database) polls forever: the page stays `polling` and therefore busy, and on
|
|
744
|
+
* the platform every read BROKERS, so a tab's worth of dead ids keeps
|
|
745
|
+
* sandboxes resident. A small budget is kept anyway, because the first read
|
|
746
|
+
* can race a replica that has not yet seen the run.
|
|
747
|
+
*/
|
|
748
|
+
const onMissing = (signal) => {
|
|
749
|
+
missing += 1;
|
|
750
|
+
if (missing < 3) return false;
|
|
751
|
+
if (!signal.aborted) onError(`No workflow run ${runId}`);
|
|
752
|
+
return true;
|
|
753
|
+
};
|
|
754
|
+
const read = async (signal) => {
|
|
755
|
+
try {
|
|
756
|
+
const next = await getClient().get(runId);
|
|
757
|
+
if (signal.aborted) return true;
|
|
758
|
+
if (!next) return onMissing(signal);
|
|
759
|
+
missing = 0;
|
|
760
|
+
onRun(next);
|
|
761
|
+
return isTerminal$1(next);
|
|
762
|
+
} catch (err) {
|
|
763
|
+
if (!signal.aborted) onError(errorMessage(err));
|
|
764
|
+
return false;
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
return repeatUntil(intervalMs, async (signal) => {
|
|
768
|
+
if (!await read(signal)) return false;
|
|
769
|
+
if (!signal.aborted) onStopped();
|
|
770
|
+
return true;
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Watch one run until it reaches a terminal status.
|
|
775
|
+
*
|
|
776
|
+
* A watch rather than a subscription because a run is durable and the page is
|
|
777
|
+
* not: it can complete while the tab is closed, on a different sandbox, hours
|
|
778
|
+
* later. There is no session to reconnect — the id is the whole state.
|
|
779
|
+
*
|
|
780
|
+
* The stream (`GET /runs/:id/events`) is tried first and the poll is its
|
|
781
|
+
* fallback, so an agent deployed before that route existed still works. Watching
|
|
782
|
+
* STOPS on a terminal status, so a finished run costs nothing; passing
|
|
783
|
+
* `undefined` (nothing started yet) also costs nothing.
|
|
784
|
+
*
|
|
785
|
+
* @typeParam R - The workflow's output type. Supplying it is what makes
|
|
786
|
+
* `run.status === "completed"` narrow to a typed `run.output` instead of
|
|
787
|
+
* `unknown`. Derive it with `WorkflowOutputOf<typeof myWorkflow>` — a
|
|
788
|
+
* type-only import of `agent.ts` is erased, so it costs the bundle nothing.
|
|
789
|
+
*
|
|
790
|
+
* @public
|
|
791
|
+
*/
|
|
792
|
+
function useWorkflowRun(runId, opts = {}) {
|
|
793
|
+
const { api, intervalMs = DEFAULT_WORKFLOW_POLL_MS } = opts;
|
|
794
|
+
const [run, setRun] = useState(void 0);
|
|
795
|
+
const [error, setError] = useState(void 0);
|
|
796
|
+
/**
|
|
797
|
+
* Has the watch stopped for a reason the snapshot does not show?
|
|
798
|
+
*
|
|
799
|
+
* Only one such reason exists — an id the agent kept reporting as unknown,
|
|
800
|
+
* past {@link MAX_MISSING_READS} — and it leaves `run` undefined, so `polling`
|
|
801
|
+
* derived from `isTerminal(run)` alone would stay true forever.
|
|
802
|
+
*/
|
|
803
|
+
const [stopped, setStopped] = useState(false);
|
|
804
|
+
const getClient = useWorkflowApiRef(api);
|
|
805
|
+
useEffect(() => {
|
|
806
|
+
setRun(void 0);
|
|
807
|
+
setError(void 0);
|
|
808
|
+
setStopped(false);
|
|
809
|
+
if (!runId) return;
|
|
810
|
+
const onRun = (next) => {
|
|
811
|
+
setRun(next);
|
|
812
|
+
setError(void 0);
|
|
813
|
+
};
|
|
814
|
+
let stopPoll;
|
|
815
|
+
const stopStream = watchRunEvents(getClient, runId, onRun, () => setStopped(true), () => {
|
|
816
|
+
stopPoll = pollUntilTerminal(getClient, runId, intervalMs, onRun, setError, () => setStopped(true));
|
|
817
|
+
});
|
|
818
|
+
return () => {
|
|
819
|
+
stopStream();
|
|
820
|
+
stopPoll?.();
|
|
821
|
+
};
|
|
822
|
+
}, [
|
|
823
|
+
runId,
|
|
824
|
+
intervalMs,
|
|
825
|
+
getClient
|
|
826
|
+
]);
|
|
827
|
+
return {
|
|
828
|
+
run,
|
|
829
|
+
error,
|
|
830
|
+
polling: runId !== void 0 && !stopped && !isTerminal$1(run)
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
//#endregion
|
|
834
|
+
//#region use-workflow-form.ts
|
|
835
|
+
/**
|
|
836
|
+
* The two hooks a FORM needs, as against the one a status view does.
|
|
837
|
+
*
|
|
838
|
+
* `useWorkflowRun` (`workflow-client.ts`) watches a run you already have.
|
|
839
|
+
* These two are what comes before it: `useWorkflows` reads the declared
|
|
840
|
+
* workflows so `<WorkflowFields>` can render a form from a schema, and
|
|
841
|
+
* `useWorkflowSubmit` starts a run and hands the id straight to
|
|
842
|
+
* `useWorkflowRun`.
|
|
843
|
+
*
|
|
844
|
+
* ## `useWorkflowSubmit` — a form's two halves in one hook
|
|
845
|
+
*
|
|
846
|
+
* A page that submits a workflow always needs the same four pieces of state:
|
|
847
|
+
* the run id, whether a submit is in flight, whether the RUN is still going, and
|
|
848
|
+
* whichever of the two failed. `link-digest` writes them out by hand, which is
|
|
849
|
+
* the right shape for a template teaching the primitives and the wrong shape to
|
|
850
|
+
* write a third time — and it is easy to get subtly wrong: dropping the previous
|
|
851
|
+
* run id before the new `POST` returns is what stops a finished result sitting
|
|
852
|
+
* under a form that is already submitting again.
|
|
853
|
+
*
|
|
854
|
+
* So this is `api.start` plus {@link useWorkflowRun}, with the state between
|
|
855
|
+
* them. It adds no transport of its own and holds no run state of its own; the
|
|
856
|
+
* watching (stream first, poll as its fallback, terminal stops) is entirely
|
|
857
|
+
* `useWorkflowRun`'s, and `run` here IS its run.
|
|
858
|
+
*
|
|
859
|
+
* ## Why it starts ASYNCHRONOUSLY even though a synchronous call exists
|
|
860
|
+
*
|
|
861
|
+
* `api.startAndWait` would collapse this to one request, and it is the wrong
|
|
862
|
+
* default for a page: it holds a socket open for up to a minute, answers nothing
|
|
863
|
+
* until it settles, and a page has `useWorkflowRun` — which survives a reload,
|
|
864
|
+
* shows progress, and costs one stream. The synchronous call is for callers with
|
|
865
|
+
* nowhere to put a watch (a script, a cron, a form POST from a server). Pass
|
|
866
|
+
* `wait` here when the page really does want one request, and the run is
|
|
867
|
+
* followed from the same id either way.
|
|
868
|
+
*/
|
|
869
|
+
/**
|
|
870
|
+
* Read the agent's declared workflows.
|
|
871
|
+
*
|
|
872
|
+
* What `<WorkflowFields>` renders a form FROM: each summary carries the JSON
|
|
873
|
+
* Schema of that workflow's input, converted server-side precisely so a browser
|
|
874
|
+
* can read it.
|
|
875
|
+
*
|
|
876
|
+
* The failure is reported rather than swallowed, because the alternative is an
|
|
877
|
+
* empty list — which renders as a form with no fields and reads as "this agent
|
|
878
|
+
* declares no workflows" about an agent that was merely unreachable.
|
|
879
|
+
*
|
|
880
|
+
* @public
|
|
881
|
+
*/
|
|
882
|
+
function useWorkflows(opts = {}) {
|
|
883
|
+
const { api, skip = false } = opts;
|
|
884
|
+
const [state, setState] = useState({
|
|
885
|
+
workflows: [],
|
|
886
|
+
loading: !skip,
|
|
887
|
+
error: void 0
|
|
888
|
+
});
|
|
889
|
+
const getClient = useWorkflowApiRef(api);
|
|
890
|
+
useEffect(() => {
|
|
891
|
+
if (skip) return;
|
|
892
|
+
let cancelled = false;
|
|
893
|
+
getClient().list().then((workflows) => {
|
|
894
|
+
if (!cancelled) setState({
|
|
895
|
+
workflows,
|
|
896
|
+
loading: false,
|
|
897
|
+
error: void 0
|
|
898
|
+
});
|
|
899
|
+
}).catch((err) => {
|
|
900
|
+
if (cancelled) return;
|
|
901
|
+
setState({
|
|
902
|
+
workflows: [],
|
|
903
|
+
loading: false,
|
|
904
|
+
error: errorMessage(err)
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
return () => {
|
|
908
|
+
cancelled = true;
|
|
909
|
+
};
|
|
910
|
+
}, [skip, getClient]);
|
|
911
|
+
return state;
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Replace every `File` in a submitted form with the id of a stored upload.
|
|
915
|
+
*
|
|
916
|
+
* Sequential rather than `Promise.all`: these are large bodies, and a form with
|
|
917
|
+
* two 200 MB recordings should send them one after another rather than compete
|
|
918
|
+
* for the same connection.
|
|
919
|
+
*
|
|
920
|
+
* Anything that is not a `File` (or an array of them) passes through untouched,
|
|
921
|
+
* so this is invisible to every form that has none — including one whose values
|
|
922
|
+
* are not an object at all, which `submit` accepts.
|
|
923
|
+
*/
|
|
924
|
+
async function uploadFiles(api, input) {
|
|
925
|
+
if (!isRecord(input)) return input;
|
|
926
|
+
const entries = Object.entries(input);
|
|
927
|
+
const out = {};
|
|
928
|
+
for (const [name, value] of entries) if (value instanceof File) out[name] = (await api.upload(value)).id;
|
|
929
|
+
else if (Array.isArray(value) && value.length > 0 && value.every((one) => one instanceof File)) {
|
|
930
|
+
const ids = [];
|
|
931
|
+
for (const file of value) ids.push((await api.upload(file)).id);
|
|
932
|
+
out[name] = ids;
|
|
933
|
+
} else out[name] = value;
|
|
934
|
+
return out;
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Start a workflow from a form, and follow the run it creates.
|
|
938
|
+
*
|
|
939
|
+
* @typeParam R - The workflow's output type, which is what makes
|
|
940
|
+
* `run.status === "completed"` narrow to a typed `run.output`. Derive it with
|
|
941
|
+
* `WorkflowOutputOf<typeof myWorkflow>`.
|
|
942
|
+
*
|
|
943
|
+
* @example
|
|
944
|
+
* ```tsx
|
|
945
|
+
* import { Form, SubmitButton, TextField, useWorkflowSubmit } from "@alexkroman1/aai-ui";
|
|
946
|
+
*
|
|
947
|
+
* function DigestForm() {
|
|
948
|
+
* const { submit, run, pending, error } = useWorkflowSubmit("digest");
|
|
949
|
+
* return (
|
|
950
|
+
* <Form onSubmit={(values) => submit(values)} error={error}>
|
|
951
|
+
* <TextField name="url" label="Link" type="url" required />
|
|
952
|
+
* <SubmitButton pending={pending}>Digest</SubmitButton>
|
|
953
|
+
* {run?.status === "completed" && <p>Done.</p>}
|
|
954
|
+
* </Form>
|
|
955
|
+
* );
|
|
956
|
+
* }
|
|
957
|
+
* ```
|
|
958
|
+
*
|
|
959
|
+
* @public
|
|
960
|
+
*/
|
|
961
|
+
function useWorkflowSubmit(workflow, opts = {}) {
|
|
962
|
+
const { api, key, wait, intervalMs } = opts;
|
|
963
|
+
const [runId, setRunId] = useState(void 0);
|
|
964
|
+
const [starting, setStarting] = useState(false);
|
|
965
|
+
const [startError, setStartError] = useState(void 0);
|
|
966
|
+
const getClient = useWorkflowApiRef(api);
|
|
967
|
+
const tracked = useWorkflowRun(runId, {
|
|
968
|
+
...api && { api },
|
|
969
|
+
...omitUndefined({ intervalMs })
|
|
970
|
+
});
|
|
971
|
+
return {
|
|
972
|
+
submit: useCallback(async (input) => {
|
|
973
|
+
const client = getClient();
|
|
974
|
+
setStarting(true);
|
|
975
|
+
setStartError(void 0);
|
|
976
|
+
setRunId(void 0);
|
|
977
|
+
try {
|
|
978
|
+
const options = omitUndefined({ key });
|
|
979
|
+
const started = await uploadFiles(client, input);
|
|
980
|
+
setRunId(wait === void 0 ? await client.start(workflow, started, options) : (await client.startAndWait(workflow, started, {
|
|
981
|
+
...options,
|
|
982
|
+
wait
|
|
983
|
+
})).runId);
|
|
984
|
+
} catch (err) {
|
|
985
|
+
setStartError(errorMessage(err));
|
|
986
|
+
} finally {
|
|
987
|
+
setStarting(false);
|
|
988
|
+
}
|
|
989
|
+
}, [
|
|
990
|
+
workflow,
|
|
991
|
+
key,
|
|
992
|
+
wait,
|
|
993
|
+
getClient
|
|
994
|
+
]),
|
|
995
|
+
reset: useCallback(() => {
|
|
996
|
+
setRunId(void 0);
|
|
997
|
+
setStartError(void 0);
|
|
998
|
+
}, []),
|
|
999
|
+
run: tracked.run,
|
|
1000
|
+
pending: starting || tracked.polling,
|
|
1001
|
+
error: startError ?? tracked.error
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
//#endregion
|
|
1005
|
+
//#region components/workflow-fields.tsx
|
|
1006
|
+
/**
|
|
1007
|
+
* Render one field per scalar property of a workflow's input schema.
|
|
1008
|
+
*
|
|
1009
|
+
* Pass the workflow's NAME and the schema is fetched here; pass a
|
|
1010
|
+
* {@link WorkflowSummary} you already hold and nothing is fetched. The name form
|
|
1011
|
+
* is the one a page usually wants — it is the same string the submit hook takes,
|
|
1012
|
+
* and the alternative is three lines (`useWorkflows()`, a `.find()` by name, and
|
|
1013
|
+
* folding that lookup's error into the form's) whose only product is this
|
|
1014
|
+
* component's argument.
|
|
1015
|
+
*
|
|
1016
|
+
* Renders nothing when the workflow declared no schema — a workflow with no
|
|
1017
|
+
* declared input takes anything, and a form for "anything" is not a form — and
|
|
1018
|
+
* nothing while a named lookup is still in flight, so the hand-written fields
|
|
1019
|
+
* beside it are not reordered when the schema lands.
|
|
1020
|
+
*
|
|
1021
|
+
* @example
|
|
1022
|
+
* ```tsx
|
|
1023
|
+
* import { Form, SubmitButton, WorkflowFields, useWorkflowSubmit }
|
|
1024
|
+
* from "@alexkroman1/aai-ui";
|
|
1025
|
+
*
|
|
1026
|
+
* function StartRun() {
|
|
1027
|
+
* const { submit, pending, error } = useWorkflowSubmit("transcribe");
|
|
1028
|
+
* return (
|
|
1029
|
+
* <Form onSubmit={(values) => submit(values)} error={error}>
|
|
1030
|
+
* <WorkflowFields workflow="transcribe" />
|
|
1031
|
+
* <SubmitButton pending={pending}>Transcribe</SubmitButton>
|
|
1032
|
+
* </Form>
|
|
1033
|
+
* );
|
|
1034
|
+
* }
|
|
1035
|
+
* ```
|
|
1036
|
+
*
|
|
1037
|
+
* @public
|
|
1038
|
+
*/
|
|
1039
|
+
function WorkflowFields({ workflow }) {
|
|
1040
|
+
const { workflows } = useWorkflows(typeof workflow === "string" ? {} : { skip: true });
|
|
1041
|
+
const summary = typeof workflow === "string" ? workflows.find((entry) => entry.name === workflow) : workflow;
|
|
1042
|
+
const schema = asObjectSchema(summary?.inputSchema);
|
|
1043
|
+
if (!schema?.properties) return null;
|
|
1044
|
+
const required = new Set(schema.required ?? []);
|
|
1045
|
+
const uploads = new Set(summary?.uploads ?? []);
|
|
1046
|
+
return /* @__PURE__ */ jsx(Fragment, { children: Object.entries(schema.properties).map(([name, property]) => /* @__PURE__ */ jsx(SchemaField, {
|
|
1047
|
+
name,
|
|
1048
|
+
property,
|
|
1049
|
+
required: required.has(name),
|
|
1050
|
+
upload: uploads.has(name)
|
|
1051
|
+
}, name)) });
|
|
1052
|
+
}
|
|
1053
|
+
/** One property's control, or nothing when its type has no obvious one. */
|
|
1054
|
+
function SchemaField({ name, property, required, upload = false }) {
|
|
1055
|
+
const label = humanize(name);
|
|
1056
|
+
const hint = property.description === void 0 ? {} : { hint: property.description };
|
|
1057
|
+
const defaults = property.default === void 0 ? {} : { defaultValue: String(property.default) };
|
|
1058
|
+
if (upload) return /* @__PURE__ */ jsx(FileField, {
|
|
1059
|
+
name,
|
|
1060
|
+
label,
|
|
1061
|
+
required,
|
|
1062
|
+
upload: true,
|
|
1063
|
+
...hint
|
|
1064
|
+
});
|
|
1065
|
+
if (Array.isArray(property.enum) && property.enum.length > 0) return /* @__PURE__ */ jsx(SelectField, {
|
|
1066
|
+
name,
|
|
1067
|
+
label,
|
|
1068
|
+
required,
|
|
1069
|
+
options: property.enum.map((value) => String(value)),
|
|
1070
|
+
...hint,
|
|
1071
|
+
...defaults
|
|
1072
|
+
});
|
|
1073
|
+
switch (typeOf(property)) {
|
|
1074
|
+
case "boolean": return /* @__PURE__ */ jsx(CheckboxField, {
|
|
1075
|
+
name,
|
|
1076
|
+
label,
|
|
1077
|
+
defaultChecked: property.default === true,
|
|
1078
|
+
...hint
|
|
1079
|
+
});
|
|
1080
|
+
case "number":
|
|
1081
|
+
case "integer": return /* @__PURE__ */ jsx(NumberField, {
|
|
1082
|
+
name,
|
|
1083
|
+
label,
|
|
1084
|
+
required,
|
|
1085
|
+
step: typeOf(property) === "integer" ? 1 : "any",
|
|
1086
|
+
...hint,
|
|
1087
|
+
...defaults
|
|
1088
|
+
});
|
|
1089
|
+
case "string": return /* @__PURE__ */ jsx(TextField, {
|
|
1090
|
+
name,
|
|
1091
|
+
label,
|
|
1092
|
+
required,
|
|
1093
|
+
...hint,
|
|
1094
|
+
...defaults
|
|
1095
|
+
});
|
|
1096
|
+
default: return null;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
/** A property's type, taking the first non-null member of a union. */
|
|
1100
|
+
function typeOf(property) {
|
|
1101
|
+
const { type } = property;
|
|
1102
|
+
if (typeof type === "string") return type;
|
|
1103
|
+
return Array.isArray(type) ? type.find((member) => member !== "null") : void 0;
|
|
1104
|
+
}
|
|
1105
|
+
/** The listing's `unknown` schema as the object shape this reads, when it is one. */
|
|
1106
|
+
function asObjectSchema(schema) {
|
|
1107
|
+
return isRecord(schema) ? schema : void 0;
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* A property name as a label — `recordingId` → `Recording id`.
|
|
1111
|
+
*
|
|
1112
|
+
* A default, not a policy: a schema whose labels matter should carry a
|
|
1113
|
+
* `.describe()`, and an author who wants exact control writes the field.
|
|
1114
|
+
*/
|
|
1115
|
+
function humanize(name) {
|
|
1116
|
+
const spaced = name.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim().toLowerCase();
|
|
1117
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
1118
|
+
}
|
|
1119
|
+
//#endregion
|
|
1120
|
+
//#region use-workflow-progress.ts
|
|
1121
|
+
/**
|
|
1122
|
+
* `useWorkflowProgress` — read what a run has WRITTEN while it runs.
|
|
1123
|
+
*
|
|
1124
|
+
* The sibling of `useWorkflowRun`, and the split between them is the whole
|
|
1125
|
+
* reason this exists. That hook reports a run's STATE: the status transitions
|
|
1126
|
+
* the world records, which every run has. This reports what the run itself
|
|
1127
|
+
* wrote through `getWritable()`, which is the only thing a long run can say
|
|
1128
|
+
* before it finishes — a snapshot carries a status and, once terminal, an
|
|
1129
|
+
* output, and nothing in between. A page that shows only status shows
|
|
1130
|
+
* "Working…" for ten minutes and then a result.
|
|
1131
|
+
*
|
|
1132
|
+
* ## There is no poll fallback, and that is not an omission
|
|
1133
|
+
*
|
|
1134
|
+
* `useWorkflowRun` degrades to polling `GET /runs/:id` because a run's STATE is
|
|
1135
|
+
* readable that way. A run's written chunks are not: the stream is the only
|
|
1136
|
+
* route to them, so an agent that does not serve it has no progress to give and
|
|
1137
|
+
* `supported` says so once, rather than a poll pretending to look for something
|
|
1138
|
+
* that is not there. A page renders its status line either way.
|
|
1139
|
+
*
|
|
1140
|
+
* ## Chunks are RETAINED, so this is a replay as much as a tail
|
|
1141
|
+
*
|
|
1142
|
+
* The run's stream keeps every chunk, so a page that mounts late — a reload, a
|
|
1143
|
+
* second tab, a link opened tomorrow — reads the whole history from index 0 and
|
|
1144
|
+
* arrives at the same list as one that watched throughout. That is what makes a
|
|
1145
|
+
* durable run's progress durable too, and it is why the default `startIndex` is
|
|
1146
|
+
* 0 rather than "from now": a tail-only default would make the same page show
|
|
1147
|
+
* different things depending on when it opened.
|
|
1148
|
+
*
|
|
1149
|
+
* ## It RE-OPENS while the run is live, because a progress read is bounded
|
|
1150
|
+
*
|
|
1151
|
+
* The route answers with the chunks written when the request arrived and then
|
|
1152
|
+
* ends, reporting `complete` — whether the run itself was terminal. It has to:
|
|
1153
|
+
* a workflow stream signals its end only once CLOSED, and a progress channel
|
|
1154
|
+
* written by one step after another is never closed, so a read that waited for
|
|
1155
|
+
* the end would hang forever on a finished run. (It did: see the route's own
|
|
1156
|
+
* doc.)
|
|
1157
|
+
*
|
|
1158
|
+
* So this hook re-opens from where it left off until a read comes back
|
|
1159
|
+
* `complete`. That is a poll, and the honest description of progress is a durable
|
|
1160
|
+
* log rather than a socket — but it is a poll of a CHEAP shape: each read asks
|
|
1161
|
+
* only for chunks past the last index it saw, so a quiet run costs an empty
|
|
1162
|
+
* answer rather than the whole log again.
|
|
1163
|
+
*/
|
|
1164
|
+
/** How often a live run's progress is re-read once a bounded read has ended. */
|
|
1165
|
+
const DEFAULT_PROGRESS_POLL_MS = 1e3;
|
|
1166
|
+
/**
|
|
1167
|
+
* Drain one bounded read's frames, reporting how it ended and everything it
|
|
1168
|
+
* carried.
|
|
1169
|
+
*
|
|
1170
|
+
* The chunks are RETURNED rather than handed over one at a time, and that is
|
|
1171
|
+
* what lets the hook commit a whole read in one React update: a per-chunk
|
|
1172
|
+
* callback re-rendered the page once per progress line and rebuilt the list
|
|
1173
|
+
* each time, which for a fan-out writing a line per segment is an O(n²) copy of
|
|
1174
|
+
* the log a reader can already only see one frame at a time. A read is bounded
|
|
1175
|
+
* by construction (see the module doc), so buffering one is bounded too.
|
|
1176
|
+
*/
|
|
1177
|
+
async function consumeFrames(body, signal) {
|
|
1178
|
+
const chunks = [];
|
|
1179
|
+
let ending = "partial";
|
|
1180
|
+
for await (const frame of sseFrames(body, signal)) if (frame.event === "chunk") chunks.push(frame.data);
|
|
1181
|
+
else if (frame.event === "done") ending = frame.data?.complete ? "complete" : "partial";
|
|
1182
|
+
else if (frame.event === "missing") return {
|
|
1183
|
+
ending: "complete",
|
|
1184
|
+
chunks
|
|
1185
|
+
};
|
|
1186
|
+
return {
|
|
1187
|
+
ending,
|
|
1188
|
+
chunks
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Read one run's progress until it is complete, reporting each read's chunks.
|
|
1193
|
+
*
|
|
1194
|
+
* Module-level rather than inline in the hook, so the loop reads without React in
|
|
1195
|
+
* the way — the same split `useWorkflowRun` makes with `pollUntilTerminal`.
|
|
1196
|
+
*
|
|
1197
|
+
* Every re-open asks from an ABSOLUTE position past the last chunk read, so a
|
|
1198
|
+
* read only ever fetches what this reader has not seen. That is what keeps the
|
|
1199
|
+
* poll cheap: a quiet run answers with a bare `done` rather than the whole log
|
|
1200
|
+
* again.
|
|
1201
|
+
*
|
|
1202
|
+
* ## A negative `startIndex` is resolved on the FIRST read, not carried
|
|
1203
|
+
*
|
|
1204
|
+
* "The last N lines" names no position a later read can resume from — the tail
|
|
1205
|
+
* it counts back from moves with every line the run writes. Carrying it meant a
|
|
1206
|
+
* re-open asking for everything from 0 and dropping `seen` chunks off the
|
|
1207
|
+
* FRONT, which is a different set entirely: a reader that opened at
|
|
1208
|
+
* `startIndex: -3` on a 10-line log holds lines 7-9, and its next read handed
|
|
1209
|
+
* over lines 3 onwards — four lines it never asked for, then the three it
|
|
1210
|
+
* already had, in that order. The dedupe the old comment claimed would need the
|
|
1211
|
+
* first read's absolute tail, which the reader never learned.
|
|
1212
|
+
*
|
|
1213
|
+
* So the first read is issued from 0 instead, and only its last N chunks are
|
|
1214
|
+
* handed over. The window the caller asked for is unchanged, and the reader now
|
|
1215
|
+
* knows exactly where it is — every read after it is the ordinary absolute case.
|
|
1216
|
+
* The cost is that the first read transfers the whole log, which is what the
|
|
1217
|
+
* DEFAULT (`startIndex: 0`, "replay everything") already does.
|
|
1218
|
+
*/
|
|
1219
|
+
function readProgressUntilComplete(getClient, runId, options, intervalMs, onChunks, onEnded) {
|
|
1220
|
+
const start = options.startIndex ?? 0;
|
|
1221
|
+
const tail = start < 0 ? -start : void 0;
|
|
1222
|
+
let next = tail === void 0 ? start : 0;
|
|
1223
|
+
let firstRead = true;
|
|
1224
|
+
/** One bounded read. Resolves how it ended. */
|
|
1225
|
+
const readOnce = async (signal) => {
|
|
1226
|
+
const res = await getClient().streamOutput(runId, {
|
|
1227
|
+
...omitUndefined({
|
|
1228
|
+
namespace: options.namespace,
|
|
1229
|
+
startIndex: next === 0 ? void 0 : next
|
|
1230
|
+
}),
|
|
1231
|
+
signal
|
|
1232
|
+
});
|
|
1233
|
+
if (!(res.ok && res.body)) return "unsupported";
|
|
1234
|
+
const { ending, chunks } = await consumeFrames(res.body, signal);
|
|
1235
|
+
next += chunks.length;
|
|
1236
|
+
const fresh = firstRead && tail !== void 0 ? chunks.slice(-tail) : chunks;
|
|
1237
|
+
firstRead = false;
|
|
1238
|
+
if (fresh.length > 0 && !signal.aborted) onChunks(fresh);
|
|
1239
|
+
return ending;
|
|
1240
|
+
};
|
|
1241
|
+
return repeatUntil(intervalMs, async (signal) => {
|
|
1242
|
+
let ending;
|
|
1243
|
+
try {
|
|
1244
|
+
ending = await readOnce(signal);
|
|
1245
|
+
} catch {
|
|
1246
|
+
ending = "partial";
|
|
1247
|
+
}
|
|
1248
|
+
if (signal.aborted) return true;
|
|
1249
|
+
if (ending === "partial") return false;
|
|
1250
|
+
onEnded(ending);
|
|
1251
|
+
return true;
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Follow one run's progress stream.
|
|
1256
|
+
*
|
|
1257
|
+
* Passing `undefined` (nothing started yet) costs nothing, and reading stops for
|
|
1258
|
+
* good once a read reports the run terminal — so a finished run costs one read.
|
|
1259
|
+
*
|
|
1260
|
+
* @example
|
|
1261
|
+
* ```tsx
|
|
1262
|
+
* import { useWorkflowProgress } from "@alexkroman1/aai-ui";
|
|
1263
|
+
*
|
|
1264
|
+
* function Progress({ runId }: { runId?: string }) {
|
|
1265
|
+
* const { progress, streaming, supported } = useWorkflowProgress(runId);
|
|
1266
|
+
* if (!supported) return null;
|
|
1267
|
+
* return (
|
|
1268
|
+
* <pre>
|
|
1269
|
+
* {progress.join("\n")}
|
|
1270
|
+
* {streaming && "\n…"}
|
|
1271
|
+
* </pre>
|
|
1272
|
+
* );
|
|
1273
|
+
* }
|
|
1274
|
+
* ```
|
|
1275
|
+
*
|
|
1276
|
+
* @typeParam T - What the workflow writes. Defaults to `string`, which is what
|
|
1277
|
+
* a progress channel usually carries; a workflow writing objects names its own
|
|
1278
|
+
* shape. Nothing in the browser can verify it — the route describes no type —
|
|
1279
|
+
* so this is the page's assertion about its own agent, narrowed once here
|
|
1280
|
+
* rather than at every read.
|
|
1281
|
+
*
|
|
1282
|
+
* @public
|
|
1283
|
+
*/
|
|
1284
|
+
function useWorkflowProgress(runId, opts = {}) {
|
|
1285
|
+
const { api, namespace, startIndex, intervalMs = DEFAULT_PROGRESS_POLL_MS } = opts;
|
|
1286
|
+
const [progress, setProgress] = useState([]);
|
|
1287
|
+
const [streaming, setStreaming] = useState(false);
|
|
1288
|
+
const [supported, setSupported] = useState(true);
|
|
1289
|
+
const getClient = useWorkflowApiRef(api);
|
|
1290
|
+
useEffect(() => {
|
|
1291
|
+
setProgress([]);
|
|
1292
|
+
setSupported(true);
|
|
1293
|
+
setStreaming(false);
|
|
1294
|
+
if (!runId) return;
|
|
1295
|
+
setStreaming(true);
|
|
1296
|
+
const stop = readProgressUntilComplete(getClient, runId, {
|
|
1297
|
+
namespace,
|
|
1298
|
+
startIndex
|
|
1299
|
+
}, intervalMs, (chunks) => setProgress((seen) => [...seen, ...chunks]), (ending) => {
|
|
1300
|
+
setStreaming(false);
|
|
1301
|
+
if (ending === "unsupported") setSupported(false);
|
|
1302
|
+
});
|
|
1303
|
+
return () => {
|
|
1304
|
+
stop();
|
|
1305
|
+
setStreaming(false);
|
|
1306
|
+
};
|
|
1307
|
+
}, [
|
|
1308
|
+
runId,
|
|
1309
|
+
namespace,
|
|
1310
|
+
startIndex,
|
|
1311
|
+
intervalMs,
|
|
1312
|
+
getClient
|
|
1313
|
+
]);
|
|
1314
|
+
return {
|
|
1315
|
+
progress,
|
|
1316
|
+
latest: progress.at(-1),
|
|
1317
|
+
streaming,
|
|
1318
|
+
supported
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
//#endregion
|
|
1322
|
+
//#region components/workflow-progress.tsx
|
|
1323
|
+
/** @jsxImportSource react */
|
|
1324
|
+
/**
|
|
1325
|
+
* What a run has said so far, rendered.
|
|
1326
|
+
*
|
|
1327
|
+
* The complement of a status line, and the reason both exist: a run is
|
|
1328
|
+
* `running` for its whole life, so a one-round job and a ten-round one look
|
|
1329
|
+
* identical while they happen. These lines come from the run itself (`report()`
|
|
1330
|
+
* in a `"use step"` body), which is the only channel a workflow has before it
|
|
1331
|
+
* produces an output.
|
|
1332
|
+
*
|
|
1333
|
+
* Three rules are baked in, and they are why this is a component rather than
|
|
1334
|
+
* three lines each page writes for itself — the two templates that had written
|
|
1335
|
+
* it had written all three, comments included:
|
|
1336
|
+
*
|
|
1337
|
+
* - **It renders nothing until there is something to render.** `supported` is
|
|
1338
|
+
* what keeps this from being an empty box forever on an agent deployed before
|
|
1339
|
+
* progress streams existed: "wrote nothing yet" and "serves no stream" are
|
|
1340
|
+
* indistinguishable from the chunk list alone.
|
|
1341
|
+
* - **The lines are TEXT, not elements.** They are append-only and two rounds
|
|
1342
|
+
* legitimately produce identical text, so there is no stable per-line key to
|
|
1343
|
+
* give React. Joining sidesteps the question instead of suppressing the lint
|
|
1344
|
+
* rule that asks it.
|
|
1345
|
+
* - **They REPLAY.** Chunks are retained with the run, so a reload mid-run —
|
|
1346
|
+
* or opening a finished run tomorrow — shows how it got there rather than an
|
|
1347
|
+
* empty box. That is `useWorkflowProgress`'s doing; this is what makes it
|
|
1348
|
+
* visible.
|
|
1349
|
+
*
|
|
1350
|
+
* @example
|
|
1351
|
+
* ```tsx
|
|
1352
|
+
* import { WorkflowProgress } from "@alexkroman1/aai-ui";
|
|
1353
|
+
*
|
|
1354
|
+
* function RunPanel({ runId }: { runId: string }) {
|
|
1355
|
+
* return <WorkflowProgress runId={runId} />;
|
|
1356
|
+
* }
|
|
1357
|
+
* ```
|
|
1358
|
+
*
|
|
1359
|
+
* @param runId - The run to read. `undefined` renders nothing, so a page may
|
|
1360
|
+
* pass its state straight through before a run exists.
|
|
1361
|
+
* @param api - The workflow API client, when the page holds its own. Defaults
|
|
1362
|
+
* to the one `page()` installs.
|
|
1363
|
+
* @param className - Replaces the default classes rather than extending them,
|
|
1364
|
+
* so a custom chrome is not fighting a default it did not ask for.
|
|
1365
|
+
* @param placeholder - Rendered instead of nothing while the run has said
|
|
1366
|
+
* nothing yet — for a page that would otherwise reflow when the first line
|
|
1367
|
+
* lands.
|
|
1368
|
+
*
|
|
1369
|
+
* @public
|
|
1370
|
+
*/
|
|
1371
|
+
function WorkflowProgress({ runId, api, className, placeholder }) {
|
|
1372
|
+
const { progress, streaming, supported } = useWorkflowProgress(runId, api ? { api } : {});
|
|
1373
|
+
if (!supported || progress.length === 0) return placeholder ?? null;
|
|
1374
|
+
return /* @__PURE__ */ jsxs("pre", {
|
|
1375
|
+
className: clsx(className ?? "whitespace-pre-wrap border-l pl-4 text-xs opacity-70"),
|
|
1376
|
+
children: [progress.join("\n"), streaming && "\n…"]
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
//#endregion
|
|
1380
|
+
//#region page.tsx
|
|
1381
|
+
/** @jsxImportSource react */
|
|
1382
|
+
/**
|
|
1383
|
+
* `page()` — mount a WORKFLOW APP's UI: React, theme, no session.
|
|
1384
|
+
*
|
|
1385
|
+
* The twin of `client()` for an agent whose front door is a form rather than a
|
|
1386
|
+
* microphone (`workflowApp()`). It is a separate entry rather than
|
|
1387
|
+
* an option on `client()` because of what `client()` unavoidably does: it
|
|
1388
|
+
* constructs a `SessionCore`, which owns a WebSocket URL provider, an audio
|
|
1389
|
+
* graph, and a microphone request. A flag would have to make all of that
|
|
1390
|
+
* conditional, and every session hook would then have to answer "what does this
|
|
1391
|
+
* mean with no session?" — so the honest split is two mounts. A page that wants
|
|
1392
|
+
* voice uses `client()`; a page that wants neither audio nor a socket uses this.
|
|
1393
|
+
*
|
|
1394
|
+
* Authoring is otherwise identical — the file is still `client.tsx`, still
|
|
1395
|
+
* React, still Tailwind, still the same theme tokens — so a workflow app reads
|
|
1396
|
+
* like every other agent. What it reaches for instead of `useSession()` is
|
|
1397
|
+
* `createWorkflowApi()` / `useWorkflowRun()`.
|
|
1398
|
+
*/
|
|
1399
|
+
/**
|
|
1400
|
+
* Mount a page for an agent whose work happens in workflows.
|
|
1401
|
+
*
|
|
1402
|
+
* There is deliberately no session, no microphone, and no socket: the component
|
|
1403
|
+
* talks to the agent over the workflow HTTP API
|
|
1404
|
+
* (`createWorkflowApi`/`useWorkflowRun`), which is durable and outlives the tab.
|
|
1405
|
+
*
|
|
1406
|
+
* @example
|
|
1407
|
+
* ```tsx
|
|
1408
|
+
* import { createWorkflowApi, page, useWorkflowRun } from "@alexkroman1/aai-ui";
|
|
1409
|
+
* import { useState } from "react";
|
|
1410
|
+
*
|
|
1411
|
+
* // Hoisted: a client built in render is a new object every render.
|
|
1412
|
+
* const api = createWorkflowApi();
|
|
1413
|
+
*
|
|
1414
|
+
* function App() {
|
|
1415
|
+
* const [runId, setRunId] = useState<string>();
|
|
1416
|
+
* const { run } = useWorkflowRun(runId, { api });
|
|
1417
|
+
* return (
|
|
1418
|
+
* <button
|
|
1419
|
+
* type="button"
|
|
1420
|
+
* onClick={() => void api.start("digest", { topic: "ai" }).then(setRunId)}
|
|
1421
|
+
* >
|
|
1422
|
+
* {run ? run.status : "Start"}
|
|
1423
|
+
* </button>
|
|
1424
|
+
* );
|
|
1425
|
+
* }
|
|
1426
|
+
*
|
|
1427
|
+
* page({ name: "Digest", component: App });
|
|
1428
|
+
* ```
|
|
1429
|
+
*
|
|
1430
|
+
* @throws If the target element is not found in the DOM.
|
|
1431
|
+
*
|
|
1432
|
+
* @public
|
|
1433
|
+
*/
|
|
1434
|
+
function page(config) {
|
|
1435
|
+
const container = resolveContainer(config.target);
|
|
1436
|
+
if (config.name && typeof document !== "undefined") document.title = config.name;
|
|
1437
|
+
return mountRoot(container, createElement(ThemeProvider, { value: config.theme }, createElement(config.component)));
|
|
1438
|
+
}
|
|
1439
|
+
//#endregion
|
|
1440
|
+
//#region use-user-transcript.ts
|
|
1441
|
+
/**
|
|
1442
|
+
* `useUserTranscript` — what the caller is saying RIGHT NOW, read correctly.
|
|
1443
|
+
*
|
|
1444
|
+
* `SessionSnapshot.userTranscript` is `string | null`, and the two falsy values
|
|
1445
|
+
* mean different things:
|
|
1446
|
+
*
|
|
1447
|
+
* - `null` — nobody is speaking. There is no partial turn.
|
|
1448
|
+
* - `""` — speech HAS been detected and no words have come back yet. A live
|
|
1449
|
+
* session sits here for a few hundred milliseconds at the start of every turn.
|
|
1450
|
+
*
|
|
1451
|
+
* Read as one falsy check, those collapse and the indicator never appears at the
|
|
1452
|
+
* start of a turn — which is the moment it is for. So every custom chrome writes
|
|
1453
|
+
* `transcript !== null && (transcript === "" ? "…" : transcript)`, three
|
|
1454
|
+
* templates did exactly that, and each one re-derived a protocol distinction
|
|
1455
|
+
* from the type rather than from anything that told them.
|
|
1456
|
+
*
|
|
1457
|
+
* This is the same distinction as two named booleans, so a component can say
|
|
1458
|
+
* what it means: render on `speaking`, show `text`, and use the SDK's own
|
|
1459
|
+
* placeholder when there is nothing to show yet.
|
|
1460
|
+
*/
|
|
1461
|
+
/**
|
|
1462
|
+
* Placeholder for "listening, no words yet" — the `""` case above.
|
|
1463
|
+
*
|
|
1464
|
+
* A one-character ellipsis rather than three dots, because it is read by a
|
|
1465
|
+
* screen reader as an ellipsis and it does not reflow the row when the first
|
|
1466
|
+
* real word replaces it.
|
|
1467
|
+
*
|
|
1468
|
+
* @public
|
|
1469
|
+
*/
|
|
1470
|
+
const TRANSCRIBING_PLACEHOLDER = "…";
|
|
1471
|
+
/**
|
|
1472
|
+
* Subscribe to the caller's in-progress turn.
|
|
1473
|
+
*
|
|
1474
|
+
* Narrowly subscribed — a component using this re-renders at STT-partial rate,
|
|
1475
|
+
* which is exactly what it is for and exactly what a whole-page `useSession()`
|
|
1476
|
+
* should not do.
|
|
1477
|
+
*
|
|
1478
|
+
* @example
|
|
1479
|
+
* ```tsx
|
|
1480
|
+
* import { useUserTranscript } from "@alexkroman1/aai-ui";
|
|
1481
|
+
*
|
|
1482
|
+
* function LiveTranscript() {
|
|
1483
|
+
* const { speaking, text } = useUserTranscript();
|
|
1484
|
+
* if (!speaking) return null;
|
|
1485
|
+
* return <div className="italic opacity-60">{text}</div>;
|
|
1486
|
+
* }
|
|
1487
|
+
* ```
|
|
1488
|
+
*
|
|
1489
|
+
* @public
|
|
1490
|
+
*/
|
|
1491
|
+
function useUserTranscript() {
|
|
1492
|
+
const partial = useSessionSelector((snapshot) => snapshot.userTranscript);
|
|
1493
|
+
return {
|
|
1494
|
+
speaking: partial !== null,
|
|
1495
|
+
text: displayText(partial),
|
|
1496
|
+
partial
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
/** The three cases, spelled out: silent, detected-but-wordless, and words. */
|
|
1500
|
+
function displayText(partial) {
|
|
1501
|
+
if (partial === null) return "";
|
|
1502
|
+
return partial === "" ? "…" : partial;
|
|
1503
|
+
}
|
|
1504
|
+
//#endregion
|
|
1505
|
+
//#region use-workflow-runs.ts
|
|
1506
|
+
/**
|
|
1507
|
+
* The RUNS a workflow has had — the list a page shows beside its form.
|
|
1508
|
+
*
|
|
1509
|
+
* `useWorkflowRun` watches one run you already hold an id for, which is the
|
|
1510
|
+
* right shape for the run a page just started and the wrong one for everything
|
|
1511
|
+
* before it. A workflow app that only offers that leaves its own history
|
|
1512
|
+
* unreachable: a page reload drops the id, and the only way back to yesterday's
|
|
1513
|
+
* transcript is to have written the id down — which is what
|
|
1514
|
+
* `transcription-workflow` asked people to do, with a text box for pasting one.
|
|
1515
|
+
*
|
|
1516
|
+
* `GET /workflows/runs?workflow=…` has always been able to answer this. This is
|
|
1517
|
+
* the hook over it, so a page renders history instead of asking for an id.
|
|
1518
|
+
*
|
|
1519
|
+
* ## It re-reads on demand, and does not poll on its own
|
|
1520
|
+
*
|
|
1521
|
+
* A list is not a live view: the run a page cares about right now is already
|
|
1522
|
+
* being watched by `useWorkflowRun`, and a second polling loop over the whole
|
|
1523
|
+
* history would broker N requests a minute on the platform to re-learn what the
|
|
1524
|
+
* first one already knows. So this reads once and hands back `refresh` — which
|
|
1525
|
+
* a page calls when its own run settles, which is exactly when the list is
|
|
1526
|
+
* stale.
|
|
1527
|
+
*/
|
|
1528
|
+
/**
|
|
1529
|
+
* Read a workflow's recent runs.
|
|
1530
|
+
*
|
|
1531
|
+
* @typeParam R - The workflow's output type, so a completed run's `output` is
|
|
1532
|
+
* typed rather than `unknown`. Derive it with `WorkflowOutputOf`.
|
|
1533
|
+
*
|
|
1534
|
+
* @example
|
|
1535
|
+
* ```tsx
|
|
1536
|
+
* import { useWorkflowRuns } from "@alexkroman1/aai-ui";
|
|
1537
|
+
*
|
|
1538
|
+
* function History() {
|
|
1539
|
+
* const { runs } = useWorkflowRuns("transcribe", { limit: 10 });
|
|
1540
|
+
* return <ul>{runs.map((run) => <li key={run.runId}>{run.status}</li>)}</ul>;
|
|
1541
|
+
* }
|
|
1542
|
+
* ```
|
|
1543
|
+
*
|
|
1544
|
+
* @public
|
|
1545
|
+
*/
|
|
1546
|
+
function useWorkflowRuns(workflow, opts = {}) {
|
|
1547
|
+
const { api, limit, key, skip = false } = opts;
|
|
1548
|
+
const [runs, setRuns] = useState([]);
|
|
1549
|
+
const [loading, setLoading] = useState(!skip && workflow !== void 0);
|
|
1550
|
+
const [error, setError] = useState(void 0);
|
|
1551
|
+
const getClient = useWorkflowApiRef(api);
|
|
1552
|
+
/**
|
|
1553
|
+
* Every read carries the epoch it started in, and a read from an earlier one
|
|
1554
|
+
* is DROPPED.
|
|
1555
|
+
*
|
|
1556
|
+
* Bumped by the unmount AND by each read as it starts, which is the half that
|
|
1557
|
+
* was missing: with only the cleanup bumping, two `refresh()` calls captured
|
|
1558
|
+
* the SAME epoch, so a slow earlier read overwrote a newer one's answer with
|
|
1559
|
+
* a staler list — the exact case the drop exists for.
|
|
1560
|
+
*/
|
|
1561
|
+
const epochRef = useRef(void 0);
|
|
1562
|
+
epochRef.current ??= createEpoch();
|
|
1563
|
+
const epoch = epochRef.current;
|
|
1564
|
+
const load = useCallback(() => {
|
|
1565
|
+
if (skip || workflow === void 0) return;
|
|
1566
|
+
const client = getClient();
|
|
1567
|
+
epoch.bump();
|
|
1568
|
+
const mine = epoch.current();
|
|
1569
|
+
setLoading(true);
|
|
1570
|
+
const options = limit === void 0 ? void 0 : { limit };
|
|
1571
|
+
(key === void 0 ? client.recent(workflow, options) : client.find(workflow, key, options)).then((found) => {
|
|
1572
|
+
if (!epoch.isCurrent(mine)) return;
|
|
1573
|
+
setRuns(found);
|
|
1574
|
+
setError(void 0);
|
|
1575
|
+
setLoading(false);
|
|
1576
|
+
}).catch((err) => {
|
|
1577
|
+
if (!epoch.isCurrent(mine)) return;
|
|
1578
|
+
setError(errorMessage(err));
|
|
1579
|
+
setLoading(false);
|
|
1580
|
+
});
|
|
1581
|
+
}, [
|
|
1582
|
+
workflow,
|
|
1583
|
+
limit,
|
|
1584
|
+
key,
|
|
1585
|
+
skip,
|
|
1586
|
+
getClient,
|
|
1587
|
+
epoch
|
|
1588
|
+
]);
|
|
1589
|
+
useEffect(() => {
|
|
1590
|
+
load();
|
|
1591
|
+
return () => {
|
|
1592
|
+
epoch.bump();
|
|
1593
|
+
};
|
|
1594
|
+
}, [load, epoch]);
|
|
1595
|
+
return {
|
|
1596
|
+
runs,
|
|
1597
|
+
loading,
|
|
1598
|
+
error,
|
|
1599
|
+
refresh: load
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
//#endregion
|
|
1603
|
+
export { ApiUrlChip, AutoScroll, Button, ChatView, CheckboxField, Controls, DEFAULT_PROGRESS_POLL_MS, DEFAULT_WORKFLOW_POLL_MS, Field, FileField, Form, MAX_MISSING_READS, Markdown, MessageList, NumberField, SelectField, SessionProvider, SessionUrlChips, SidebarLayout, StartScreen, SubmitButton, TRANSCRIBING_PLACEHOLDER, TextAreaField, TextField, ThemeProvider, ToolCallRow, ToolConfigContext, UiUrlChip, VOICE_CAPTURE_CONSTRAINTS, WorkflowFields, WorkflowProgress, buildAgentUrl, client, createSessionCore, createWorkflowApi, fetchClientConfig, isTerminal, loadClientConfig, page, useAgentState, useEvent, useSession, useSessionSelector, useTheme, useToolCallStart, useToolResult, useUserTranscript, useWorkflowProgress, useWorkflowRun, useWorkflowRuns, useWorkflowSubmit, useWorkflows };
|