@alexkroman1/aai-ui 13.2.0 → 14.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -69
- package/dist/{_colors-j8XMToi9.js → _colors-CpZO-88A.js} +25 -2
- package/dist/{_module-url-C_4gRVL0.js → _module-url-C13kAJ87.js} +1 -1
- package/dist/_recover-run.d.ts +13 -7
- package/dist/_submission-state.d.ts +92 -0
- package/dist/_upload-files.d.ts +2 -2
- package/dist/_upload-report.d.ts +26 -0
- package/dist/{_utils-B6498_bm.js → _utils-DnQDM9Uy.js} +4 -4
- package/dist/_utils.d.ts +3 -3
- package/dist/_web-storage.d.ts +43 -0
- package/dist/_workflow-files.d.ts +1 -1
- package/dist/{aai-logo-CXZGPSIY.js → aai-logo-CFlomZlS.js} +1 -1
- package/dist/agent-state-labels.d.ts +60 -0
- package/dist/audio.js +21 -15
- package/dist/{chat-view-DDTtrh7N.js → chat-view-C_3T7Ln8.js} +100 -33
- package/dist/{client-config-BT_kWID5.js → client-config-DJQHnYjm.js} +5 -5
- package/dist/client-config.d.ts +4 -4
- package/dist/client-dir.d.ts +1 -1
- package/dist/client-dir.js +2 -2
- package/dist/components/_colors.d.ts +23 -0
- package/dist/components/_form-readiness.d.ts +1 -1
- package/dist/components/bullet-list.d.ts +74 -0
- package/dist/components/button.js +4 -4
- package/dist/components/chat-view.js +1 -1
- package/dist/components/console-shell.d.ts +16 -20
- package/dist/components/controls.js +4 -4
- package/dist/components/facts.d.ts +81 -0
- package/dist/components/form-fields.d.ts +6 -6
- package/dist/components/form-types.d.ts +1 -1
- package/dist/components/form.d.ts +1 -1
- package/dist/components/message-list.js +1 -1
- package/dist/components/session-error-banner.d.ts +69 -0
- package/dist/components/sidebar-layout.js +1 -1
- package/dist/components/start-screen.js +4 -4
- package/dist/components/tool-call-block.js +1 -1
- package/dist/components/tool-config-context.d.ts +1 -1
- package/dist/components/workflow-progress.d.ts +11 -4
- package/dist/context.d.ts +142 -19
- package/dist/context.js +156 -18
- package/dist/default-client/assets/{audio-BuDICbPf.js → audio-9zQsNc1w.js} +1 -1
- package/dist/default-client/assets/index-BTv30Z4F.css +2 -0
- package/dist/default-client/assets/index-RAZ-29Sz.js +284 -0
- package/dist/default-client/index.html +2 -2
- package/dist/default-client.d.ts +1 -1
- package/dist/define-client.d.ts +19 -19
- package/dist/define-client.js +20 -20
- package/dist/{eyebrow-C6ZFuiz6.js → eyebrow-UfmSz9yy.js} +1 -1
- package/dist/hooks.d.ts +44 -8
- package/dist/hooks.js +20 -14
- package/dist/index.d.ts +13 -8
- package/dist/index.js +1447 -1160
- package/dist/internal.d.ts +2 -2
- package/dist/internal.js +5 -5
- package/dist/{message-list-BJYyuIcR.js → message-list-CdOnSh5m.js} +23 -16
- package/dist/page.d.ts +11 -11
- package/dist/session-core-audio-setup.d.ts +1 -1
- package/dist/session-core-dial.d.ts +0 -2
- package/dist/{session-core-DxBYsfHA.js → session-core-gwePM95B.js} +135 -62
- package/dist/session-core-messages.d.ts +2 -2
- package/dist/session-core-types.d.ts +58 -1
- package/dist/session-core.d.ts +6 -6
- package/dist/session-core.js +2 -2
- package/dist/session-resume-store.d.ts +3 -3
- package/dist/{tool-call-block-tcPQAkcP.js → tool-call-block-C2t_5fpp.js} +30 -12
- package/dist/{tool-config-context-DzAofqi_.js → tool-config-context-Es4YUzV2.js} +2 -2
- package/dist/types.d.ts +19 -4
- package/dist/types.js +3 -3
- package/dist/{url-chips-YqhCjWfQ.js → url-chips-BxhzZgk2.js} +5 -5
- package/dist/use-conversation.d.ts +1 -1
- package/dist/use-run-key.d.ts +44 -10
- package/dist/{use-user-transcript-C14qWFu2.js → use-user-transcript-uyHhzy4d.js} +4 -3
- package/dist/use-workflow-form.d.ts +64 -91
- package/dist/{use-workflow-progress-Cu0SxMyg.js → use-workflow-run-CP2ekKPV.js} +254 -257
- package/dist/use-workflow-stream.d.ts +5 -2
- package/dist/use-workflows.d.ts +77 -0
- package/dist/workflow-client.d.ts +1 -1
- package/dist/worklets/capture-processor.js +2 -2
- package/dist/worklets/playback-processor.js +2 -2
- package/package.json +6 -6
- package/styles.css +78 -0
- package/dist/default-client/assets/index-B1_ROnTJ.js +0 -284
- package/dist/default-client/assets/index-S5fkKi6B.css +0 -2
- package/dist/tsdown.config.d.ts +0 -2
package/dist/index.js
CHANGED
|
@@ -1,26 +1,190 @@
|
|
|
1
|
-
import { n as fetchClientConfig } from "./client-config-
|
|
2
|
-
import { i as AutoScroll, n as Markdown, r as useConversation, t as MessageList } from "./message-list-
|
|
3
|
-
import { ThemeProvider, useSession, useSessionSelector, useTheme } from "./context.js";
|
|
4
|
-
import {
|
|
1
|
+
import { n as fetchClientConfig } from "./client-config-DJQHnYjm.js";
|
|
2
|
+
import { i as AutoScroll, n as Markdown, r as useConversation, t as MessageList } from "./message-list-CdOnSh5m.js";
|
|
3
|
+
import { ThemeProvider, useSession, useSessionActions, useSessionError, useSessionSelector, useSessionStatus, useTheme } from "./context.js";
|
|
4
|
+
import { a as inkTint, i as focusRingStyle, n as FOCUS_RING } from "./_colors-CpZO-88A.js";
|
|
5
5
|
import { Button } from "./components/button.js";
|
|
6
|
-
import { n as ConsoleShell, t as ChatView } from "./chat-view-
|
|
7
|
-
import { n as setPageTitle } from "./_utils-
|
|
6
|
+
import { n as ConsoleShell, r as SessionErrorBanner, t as ChatView } from "./chat-view-C_3T7Ln8.js";
|
|
7
|
+
import { n as setPageTitle } from "./_utils-DnQDM9Uy.js";
|
|
8
8
|
import { Controls } from "./components/controls.js";
|
|
9
|
-
import { n as useUserTranscript } from "./use-user-transcript-
|
|
10
|
-
import { n as ToolCallRow } from "./tool-call-block-
|
|
9
|
+
import { n as useUserTranscript } from "./use-user-transcript-uyHhzy4d.js";
|
|
10
|
+
import { n as ToolCallRow } from "./tool-call-block-C2t_5fpp.js";
|
|
11
11
|
import { SidebarLayout } from "./components/sidebar-layout.js";
|
|
12
12
|
import { StartScreen } from "./components/start-screen.js";
|
|
13
|
-
import { a as
|
|
14
|
-
import { t as
|
|
15
|
-
import {
|
|
13
|
+
import { a as useWorkflowProgress, c as isTerminal, o as useWorkflowApiRef, r as useWorkflowRun, s as createWorkflowApi } from "./use-workflow-run-CP2ekKPV.js";
|
|
14
|
+
import { i as urlSlot, n as storageGet, r as storageSet, t as createBrowserSession } from "./session-core-gwePM95B.js";
|
|
15
|
+
import { mountClient, mountRoot, resolveContainer } from "./define-client.js";
|
|
16
16
|
import { useAgentState, useEvent, useToolCallStart, useToolResult } from "./hooks.js";
|
|
17
17
|
import clsx from "clsx";
|
|
18
18
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
19
|
-
import { createContext, createElement, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
|
|
19
|
+
import { createContext, createElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
20
20
|
import { errorMessage } from "@alexkroman1/aai";
|
|
21
21
|
import { formatBytes, isRecord, omitUndefined } from "@alexkroman1/aai/utils";
|
|
22
22
|
import { createEpoch } from "@alexkroman1/aai/internal";
|
|
23
|
-
//#region
|
|
23
|
+
//#region src/agent-state-labels.ts
|
|
24
|
+
/**
|
|
25
|
+
* The default label per {@link AgentState}.
|
|
26
|
+
*
|
|
27
|
+
* Override the ones your page has a better word for and keep the rest:
|
|
28
|
+
*
|
|
29
|
+
* ```ts
|
|
30
|
+
* import type { AgentState } from "@alexkroman1/aai-ui";
|
|
31
|
+
* import { AGENT_STATE_LABELS } from "@alexkroman1/aai-ui";
|
|
32
|
+
*
|
|
33
|
+
* // A dispatch board that shouts, and renames one state.
|
|
34
|
+
* const STATE_LABEL = { ...AGENT_STATE_LABELS, thinking: "Processing" };
|
|
35
|
+
* const shout = (s: AgentState) => STATE_LABEL[s].toUpperCase();
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* **Sentence case, deliberately.** A template that wants caps applies its own
|
|
39
|
+
* `.toUpperCase()`, and a template that wants Title Case is already there;
|
|
40
|
+
* shipping the shouted form instead would leave the two chromes that do not
|
|
41
|
+
* shout with a string they have to un-shout, which no case transform does
|
|
42
|
+
* correctly.
|
|
43
|
+
*
|
|
44
|
+
* Two wordings are decisions rather than transliterations of the member name:
|
|
45
|
+
*
|
|
46
|
+
* - `disconnected` is **"Idle"**. It is the state a session is in BEFORE it has
|
|
47
|
+
* ever started as well as after it ends, so it is the first word most callers
|
|
48
|
+
* see; "Disconnected" reads as a fault on a page where nothing has gone
|
|
49
|
+
* wrong yet. Both chromes that mapped this state by hand chose "Idle" too.
|
|
50
|
+
* - `connecting` and `thinking` carry an ellipsis, `listening` and `speaking`
|
|
51
|
+
* do not. The first two are waits with nothing for the caller to do; the
|
|
52
|
+
* other two describe someone actually talking. Same distinction
|
|
53
|
+
* `WORKFLOW_STATUS_LABELS` draws with its one "Working…".
|
|
54
|
+
*
|
|
55
|
+
* @public
|
|
56
|
+
*/
|
|
57
|
+
const AGENT_STATE_LABELS = {
|
|
58
|
+
disconnected: "Idle",
|
|
59
|
+
connecting: "Connecting…",
|
|
60
|
+
ready: "Ready",
|
|
61
|
+
listening: "Listening",
|
|
62
|
+
thinking: "Thinking…",
|
|
63
|
+
speaking: "Speaking",
|
|
64
|
+
error: "Error"
|
|
65
|
+
};
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/components/bullet-list.tsx
|
|
68
|
+
/** @jsxImportSource react */
|
|
69
|
+
/**
|
|
70
|
+
* A disc-bulleted list of short strings — a run's key points, findings, risks.
|
|
71
|
+
*
|
|
72
|
+
* Five pages had written this, byte-identical apart from a `text-sm` suffix on
|
|
73
|
+
* two of them, and all five had the same two defects. Both are the reason this
|
|
74
|
+
* is a component rather than four lines a page repeats:
|
|
75
|
+
*
|
|
76
|
+
* - **All five keyed by the string itself, and these lists are MODEL OUTPUT.**
|
|
77
|
+
* Two identical bullets are entirely plausible — a summariser that repeats
|
|
78
|
+
* itself is a bad summary, not a bad program — and a repeated string is then
|
|
79
|
+
* a duplicate `key`: React warns, and the two `<li>`s contend for one slot in
|
|
80
|
+
* the reconciliation. What is keyed here instead is the content PLUS how many
|
|
81
|
+
* times that content has already appeared in this list, which is unique by
|
|
82
|
+
* construction and unchanged by a re-render that did not change the text.
|
|
83
|
+
* (Position alone would also be sound — these lists are replaced wholesale by
|
|
84
|
+
* each new output and never reordered — but it is what `noArrayIndexKey`
|
|
85
|
+
* exists to talk you out of, and a unique key is one `Map` away, so there is
|
|
86
|
+
* no reason to spend a lint suppression on it.)
|
|
87
|
+
* - **Three of the five rendered an empty `<ul>` under a heading.** Two had
|
|
88
|
+
* hand-rolled `if (items.length === 0) return null` and three had not, so the
|
|
89
|
+
* same absent field was "nothing" on two pages and a stray heading with a
|
|
90
|
+
* void under it on three. Emptiness renders NOTHING here, `title` included:
|
|
91
|
+
* a heading over no bullets is a claim the run did not make.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```tsx
|
|
95
|
+
* import { BulletList } from "@alexkroman1/aai-ui";
|
|
96
|
+
*
|
|
97
|
+
* function Findings({ risks }: { risks: string[] }) {
|
|
98
|
+
* return <BulletList title="Risks" items={risks} size="sm" />;
|
|
99
|
+
* }
|
|
100
|
+
* ```
|
|
101
|
+
*
|
|
102
|
+
* @param props - Bullet-list props.
|
|
103
|
+
*
|
|
104
|
+
* @public
|
|
105
|
+
*/
|
|
106
|
+
function BulletList({ items, title, size = "base", className }) {
|
|
107
|
+
if (items.length === 0) return null;
|
|
108
|
+
const seen = /* @__PURE__ */ new Map();
|
|
109
|
+
const list = /* @__PURE__ */ jsx("ul", {
|
|
110
|
+
className: clsx("flex list-disc flex-col gap-1 pl-5", size === "sm" && "text-sm", className),
|
|
111
|
+
children: items.map((item) => {
|
|
112
|
+
const nth = seen.get(item) ?? 0;
|
|
113
|
+
seen.set(item, nth + 1);
|
|
114
|
+
return /* @__PURE__ */ jsx("li", { children: item }, `${nth}:${item}`);
|
|
115
|
+
})
|
|
116
|
+
});
|
|
117
|
+
const heading = title === void 0 || title === null || title === false ? void 0 : title;
|
|
118
|
+
if (heading === void 0) return list;
|
|
119
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
120
|
+
className: "flex flex-col gap-1",
|
|
121
|
+
children: [/* @__PURE__ */ jsx("h3", {
|
|
122
|
+
className: "text-sm font-medium opacity-70",
|
|
123
|
+
children: heading
|
|
124
|
+
}), list]
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
//#endregion
|
|
128
|
+
//#region src/components/facts.tsx
|
|
129
|
+
/** @jsxImportSource react */
|
|
130
|
+
/** What goes between two facts. A separator, not a bullet — U+00B7, not U+2022. */
|
|
131
|
+
const FACT_SEPARATOR = " · ";
|
|
132
|
+
/**
|
|
133
|
+
* A muted line of run facts, joined by `·` — "6 segments · 12:04 of audio ·
|
|
134
|
+
* 1,840 words".
|
|
135
|
+
*
|
|
136
|
+
* Nine pages had written this by hand under four different typographies for
|
|
137
|
+
* one role, two of them (`call-audit` and `spoken-summary`) byte-identical down
|
|
138
|
+
* to the payload. Three things it takes off the caller:
|
|
139
|
+
*
|
|
140
|
+
* - **The separator cannot be forgotten, and neither can the space around it.**
|
|
141
|
+
* Four of the nine carried a literal `{" "}` at the end of a line, because
|
|
142
|
+
* Prettier's wrap ate the space that made `· ` read as a separator rather
|
|
143
|
+
* than as punctuation glued to the next word. A line that is correct only
|
|
144
|
+
* because somebody remembered an invisible JSX expression is exactly the
|
|
145
|
+
* thing a component should own.
|
|
146
|
+
* - **A fact worth omitting is omitted, and by the caller's own condition.**
|
|
147
|
+
* The hand-written shape for a conditional fact was to splice the separator
|
|
148
|
+
* into the string — `{x ? \` · budget exhausted\` : ""}` — which puts the
|
|
149
|
+
* punctuation in two places and gets the leading separator wrong the moment
|
|
150
|
+
* the fact before it also disappears. Passing the condition and letting this
|
|
151
|
+
* drop it keeps the separator in one place.
|
|
152
|
+
* - **A line with nothing left to say renders NOTHING.** With every fact
|
|
153
|
+
* conditional, the alternative is a muted empty row, or a bare `·`.
|
|
154
|
+
*
|
|
155
|
+
* The facts are JOINED into one string rather than interleaved as elements, and
|
|
156
|
+
* that is why the prop is text: joined, there is no per-fact `key` to invent —
|
|
157
|
+
* the same reasoning `WorkflowProgress` gives for its log lines. A line that
|
|
158
|
+
* genuinely needs an element in it (a link) wants its own markup.
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* ```tsx
|
|
162
|
+
* import { Facts } from "@alexkroman1/aai-ui";
|
|
163
|
+
*
|
|
164
|
+
* function RunFacts({ words, cut }: { words: number; cut: number }) {
|
|
165
|
+
* return <Facts size="xs" items={[`${words} words`, cut > 0 && `${cut} blind cuts`]} />;
|
|
166
|
+
* }
|
|
167
|
+
* ```
|
|
168
|
+
*
|
|
169
|
+
* @param props - Facts-line props.
|
|
170
|
+
*
|
|
171
|
+
* @public
|
|
172
|
+
*/
|
|
173
|
+
function Facts({ items, size = "sm", as = "p", className }) {
|
|
174
|
+
const shown = items.filter((item) => item !== false && item !== null && item !== void 0 && item !== "");
|
|
175
|
+
if (shown.length === 0) return null;
|
|
176
|
+
const text = shown.join(FACT_SEPARATOR);
|
|
177
|
+
const classes = clsx(size === "xs" ? "text-xs opacity-60" : "text-sm opacity-70", className);
|
|
178
|
+
return as === "span" ? /* @__PURE__ */ jsx("span", {
|
|
179
|
+
className: classes,
|
|
180
|
+
children: text
|
|
181
|
+
}) : /* @__PURE__ */ jsx("p", {
|
|
182
|
+
className: classes,
|
|
183
|
+
children: text
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/components/_form-readiness.ts
|
|
24
188
|
/**
|
|
25
189
|
* Whether a form's own fields exist yet.
|
|
26
190
|
*
|
|
@@ -47,7 +211,7 @@ import { createEpoch } from "@alexkroman1/aai/internal";
|
|
|
47
211
|
* one describes an element that EXISTS. So this is a context rather than an
|
|
48
212
|
* attribute, and it carries the one fact a DOM read cannot recover.
|
|
49
213
|
*
|
|
50
|
-
* A form with no such children is ready by definition — `
|
|
214
|
+
* A form with no such children is ready by definition — `useFormReadiness`
|
|
51
215
|
* outside a provider reports nothing pending, so every hand-written form is
|
|
52
216
|
* unaffected and `Form` keeps working outside this package.
|
|
53
217
|
*/
|
|
@@ -106,7 +270,7 @@ function useDeclareFieldsPending(pending) {
|
|
|
106
270
|
]);
|
|
107
271
|
}
|
|
108
272
|
//#endregion
|
|
109
|
-
//#region components/_form-values.ts
|
|
273
|
+
//#region src/components/_form-values.ts
|
|
110
274
|
/**
|
|
111
275
|
* Read one `<form>`'s named controls into a plain object.
|
|
112
276
|
*
|
|
@@ -173,7 +337,7 @@ function readFiles(files, read) {
|
|
|
173
337
|
if (read === "upload") return Promise.resolve([...files]);
|
|
174
338
|
return Promise.all(files.map((file) => describeFile(file, read)));
|
|
175
339
|
}
|
|
176
|
-
/** The `data-aai-read` attribute as a {@link
|
|
340
|
+
/** The `data-aai-read` attribute as a {@link FileReadMode}, defaulting to `"none"`. */
|
|
177
341
|
function readMode(raw) {
|
|
178
342
|
return raw === "text" || raw === "dataUrl" || raw === "upload" ? raw : "none";
|
|
179
343
|
}
|
|
@@ -201,7 +365,7 @@ function dataUrl(file) {
|
|
|
201
365
|
});
|
|
202
366
|
}
|
|
203
367
|
//#endregion
|
|
204
|
-
//#region components/form-fields.tsx
|
|
368
|
+
//#region src/components/form-fields.tsx
|
|
205
369
|
/** @jsxImportSource react */
|
|
206
370
|
/**
|
|
207
371
|
* The controls a form is made of — the shell, the six fields, and the styling
|
|
@@ -274,12 +438,12 @@ function Field({ label, hint, htmlFor, className, children }) {
|
|
|
274
438
|
function useControlProps() {
|
|
275
439
|
const theme = useTheme();
|
|
276
440
|
return {
|
|
277
|
-
className: clsx("w-full rounded-aai border px-3 py-2 text-sm font-aai",
|
|
441
|
+
className: clsx("w-full rounded-aai border px-3 py-2 text-sm font-aai", FOCUS_RING, "disabled:cursor-not-allowed disabled:opacity-50"),
|
|
278
442
|
style: {
|
|
279
443
|
background: theme.surface,
|
|
280
444
|
color: theme.text,
|
|
281
445
|
borderColor: theme.border,
|
|
282
|
-
|
|
446
|
+
...focusRingStyle(theme.primary)
|
|
283
447
|
}
|
|
284
448
|
};
|
|
285
449
|
}
|
|
@@ -311,16 +475,14 @@ function useFileControlProps() {
|
|
|
311
475
|
};
|
|
312
476
|
}
|
|
313
477
|
/**
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
* Accepts every `<input>` attribute except `name` and `className`, which this
|
|
317
|
-
* component owns, plus the shared {@link FieldShell} props.
|
|
318
|
-
*
|
|
319
|
-
* @param props - {@link FieldShell} props plus `<input>` attributes.
|
|
478
|
+
* `TextField` and `NumberField`, which differ only in the `type` they set.
|
|
320
479
|
*
|
|
321
|
-
*
|
|
480
|
+
* Internal, because the two stay separate PUBLIC exports: each is named in the
|
|
481
|
+
* `components` capability contract and in the API report, and a caller writes
|
|
482
|
+
* the one whose value shape it wants — `NumberField` is the one that
|
|
483
|
+
* contributes a number to {@link FormValues}.
|
|
322
484
|
*/
|
|
323
|
-
function
|
|
485
|
+
function InputField({ defaultType, name, label, hint, className, ...rest }) {
|
|
324
486
|
const id = useId();
|
|
325
487
|
const control = useControlProps();
|
|
326
488
|
return /* @__PURE__ */ jsx(Field, {
|
|
@@ -331,13 +493,29 @@ function TextField({ name, label, hint, className, ...rest }) {
|
|
|
331
493
|
children: /* @__PURE__ */ jsx("input", {
|
|
332
494
|
id,
|
|
333
495
|
name,
|
|
334
|
-
type:
|
|
496
|
+
type: defaultType,
|
|
335
497
|
...control,
|
|
336
498
|
...rest
|
|
337
499
|
})
|
|
338
500
|
});
|
|
339
501
|
}
|
|
340
502
|
/**
|
|
503
|
+
* A single-line text input.
|
|
504
|
+
*
|
|
505
|
+
* Accepts every `<input>` attribute except `name` and `className`, which this
|
|
506
|
+
* component owns, plus the shared {@link FieldShell} props.
|
|
507
|
+
*
|
|
508
|
+
* @param props - {@link FieldShell} props plus `<input>` attributes.
|
|
509
|
+
*
|
|
510
|
+
* @public
|
|
511
|
+
*/
|
|
512
|
+
function TextField(props) {
|
|
513
|
+
return /* @__PURE__ */ jsx(InputField, {
|
|
514
|
+
defaultType: "text",
|
|
515
|
+
...props
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
341
519
|
* A number input. Contributes a NUMBER to {@link FormValues}, or nothing when
|
|
342
520
|
* left empty.
|
|
343
521
|
*
|
|
@@ -349,21 +527,10 @@ function TextField({ name, label, hint, className, ...rest }) {
|
|
|
349
527
|
*
|
|
350
528
|
* @public
|
|
351
529
|
*/
|
|
352
|
-
function NumberField(
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
label,
|
|
357
|
-
hint,
|
|
358
|
-
htmlFor: id,
|
|
359
|
-
className,
|
|
360
|
-
children: /* @__PURE__ */ jsx("input", {
|
|
361
|
-
id,
|
|
362
|
-
name,
|
|
363
|
-
type: "number",
|
|
364
|
-
...control,
|
|
365
|
-
...rest
|
|
366
|
-
})
|
|
530
|
+
function NumberField(props) {
|
|
531
|
+
return /* @__PURE__ */ jsx(InputField, {
|
|
532
|
+
defaultType: "number",
|
|
533
|
+
...props
|
|
367
534
|
});
|
|
368
535
|
}
|
|
369
536
|
/**
|
|
@@ -474,14 +641,14 @@ function CheckboxField({ name, label, hint, className, ...rest }) {
|
|
|
474
641
|
* travel in it. With `upload` the field contributes the `File` itself,
|
|
475
642
|
* `useWorkflowSubmit` stores it through `POST /workflows/uploads` before
|
|
476
643
|
* starting the run, and the input carries the upload id — which a step reads
|
|
477
|
-
* windows of with `
|
|
644
|
+
* windows of with `stepReadUpload`. Declaring the property in the workflow's
|
|
478
645
|
* `uploads` list makes `<WorkflowFields>` render exactly this, so a declared
|
|
479
646
|
* form needs no file markup at all.
|
|
480
647
|
*
|
|
481
648
|
* **Without it the field describes the file and does not read it.** `read`
|
|
482
649
|
* exists for the cases where the bytes really are small and really are the
|
|
483
650
|
* input — a CSV of ids, a config — and the size is the author's to check. See
|
|
484
|
-
* {@link
|
|
651
|
+
* {@link FileReadMode} for the four values; `upload` is shorthand for
|
|
485
652
|
* `read="upload"`.
|
|
486
653
|
*
|
|
487
654
|
* Otherwise accepts every `<input>` attribute except `name`, `className` and
|
|
@@ -512,7 +679,7 @@ function FileField({ name, label, hint, className, read = "none", upload = false
|
|
|
512
679
|
});
|
|
513
680
|
}
|
|
514
681
|
//#endregion
|
|
515
|
-
//#region components/form.tsx
|
|
682
|
+
//#region src/components/form.tsx
|
|
516
683
|
/** @jsxImportSource react */
|
|
517
684
|
/**
|
|
518
685
|
* Simple forms, for the pages that are not conversations.
|
|
@@ -654,7 +821,7 @@ function SubmitButton({ children, pending = false, pendingLabel = "Working…",
|
|
|
654
821
|
});
|
|
655
822
|
}
|
|
656
823
|
//#endregion
|
|
657
|
-
//#region components/upload-progress.tsx
|
|
824
|
+
//#region src/components/upload-progress.tsx
|
|
658
825
|
/** @jsxImportSource react */
|
|
659
826
|
/**
|
|
660
827
|
* The track behind the fill, as a tint of the theme's own ink.
|
|
@@ -766,1287 +933,1418 @@ function UploadProgressBar({ upload, onPause, onResume, className }) {
|
|
|
766
933
|
});
|
|
767
934
|
}
|
|
768
935
|
//#endregion
|
|
769
|
-
//#region
|
|
936
|
+
//#region src/use-workflows.ts
|
|
770
937
|
/**
|
|
771
|
-
*
|
|
772
|
-
*
|
|
773
|
-
* A run is durable and a page is not — which `useWorkflowRun`'s doc says, and
|
|
774
|
-
* which was only half true of the hooks above it: the run id lived in plain
|
|
775
|
-
* `useState`, so a refresh (or a same-tab navigation, or a crashed tab) left a
|
|
776
|
-
* live run with nothing anywhere able to name it. The run really did continue;
|
|
777
|
-
* the person really could not get back to it.
|
|
778
|
-
*
|
|
779
|
-
* `StartOptions.key` is the handle that survives that, and it always was — a
|
|
780
|
-
* caller's own name for a run, indexed by the agent, read back with
|
|
781
|
-
* `find(workflow, key)`. What was missing is the two lines that ASK. This is
|
|
782
|
-
* them, plus the four decisions they turn out to carry.
|
|
783
|
-
*
|
|
784
|
-
* ## It is a MOUNT-time act, not "whenever there is no run"
|
|
785
|
-
*
|
|
786
|
-
* The tempting spelling is "if we hold no run id, look one up", and it breaks
|
|
787
|
-
* `reset()`: a form put back to its initial state holds no run id, so the next
|
|
788
|
-
* pass would re-adopt the very run the person had just dismissed — a Clear
|
|
789
|
-
* button that clears nothing. So the lookup runs once per mount (and again only
|
|
790
|
-
* if the KEY changes, which is a different person's run), and every later
|
|
791
|
-
* absence of a run id is taken at face value.
|
|
792
|
-
*
|
|
793
|
-
* ## The lookup NEVER wins a race against a submit
|
|
938
|
+
* The agent's declared workflows — what a form is rendered FROM.
|
|
794
939
|
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
940
|
+
* Split out of `use-workflow-form.ts` at the 500-line cap, along the seam that
|
|
941
|
+
* file's own doc already drew: it held "the two hooks a FORM needs", and only
|
|
942
|
+
* one of them is about a RUN. This is the other one, and it shares nothing with
|
|
943
|
+
* its former neighbours but the client ref every hook here uses — no state, no
|
|
944
|
+
* run id, no upload.
|
|
945
|
+
*/
|
|
946
|
+
/**
|
|
947
|
+
* Read the agent's declared workflows.
|
|
799
948
|
*
|
|
800
|
-
*
|
|
949
|
+
* What `<WorkflowFields>` renders a form FROM: each summary carries the JSON
|
|
950
|
+
* Schema of that workflow's input, converted server-side precisely so a browser
|
|
951
|
+
* can read it.
|
|
801
952
|
*
|
|
802
|
-
* The
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
* never run anything pays a banner they can ignore. Same trade as
|
|
806
|
-
* `useWorkflows`, for the same reason: an empty answer here is a confident
|
|
807
|
-
* false statement.
|
|
953
|
+
* The failure is reported rather than swallowed, because the alternative is an
|
|
954
|
+
* empty list — which renders as a form with no fields and reads as "this agent
|
|
955
|
+
* declares no workflows" about an agent that was merely unreachable.
|
|
808
956
|
*
|
|
809
|
-
*
|
|
957
|
+
* @example
|
|
958
|
+
* ```tsx
|
|
959
|
+
* import { useWorkflows } from "@alexkroman1/aai-ui";
|
|
810
960
|
*
|
|
811
|
-
* A
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
*
|
|
961
|
+
* // A page rendering its own chrome from the listing — a picker, say. A form
|
|
962
|
+
* // for ONE workflow wants `<WorkflowFields workflow="name" />` instead,
|
|
963
|
+
* // which does this lookup itself.
|
|
964
|
+
* function WorkflowPicker({ onPick }: { onPick: (name: string) => void }) {
|
|
965
|
+
* const { workflows, loading, error } = useWorkflows();
|
|
966
|
+
* if (loading) return <p>Loading…</p>;
|
|
967
|
+
* if (error !== undefined) return <p role="alert">{error}</p>;
|
|
968
|
+
* return (
|
|
969
|
+
* <ul>
|
|
970
|
+
* {workflows.map((summary) => (
|
|
971
|
+
* <li key={summary.name}>
|
|
972
|
+
* <button type="button" onClick={() => onPick(summary.name)}>
|
|
973
|
+
* {summary.description ?? summary.name}
|
|
974
|
+
* </button>
|
|
975
|
+
* </li>
|
|
976
|
+
* ))}
|
|
977
|
+
* </ul>
|
|
978
|
+
* );
|
|
979
|
+
* }
|
|
980
|
+
* ```
|
|
818
981
|
*
|
|
819
|
-
* @param opts - See {@link
|
|
820
|
-
* @returns
|
|
821
|
-
*
|
|
822
|
-
* form inviting a second one.
|
|
982
|
+
* @param opts - See {@link UseWorkflowsOptions}.
|
|
983
|
+
* @returns The listing, its loading flag and its failure — see
|
|
984
|
+
* {@link UseWorkflowsResult}.
|
|
823
985
|
*
|
|
824
|
-
* @
|
|
986
|
+
* @public
|
|
825
987
|
*/
|
|
826
|
-
function
|
|
827
|
-
const {
|
|
828
|
-
const [
|
|
829
|
-
|
|
830
|
-
|
|
988
|
+
function useWorkflows(opts = {}) {
|
|
989
|
+
const { api, skip = false } = opts;
|
|
990
|
+
const [state, setState] = useState({
|
|
991
|
+
workflows: [],
|
|
992
|
+
loading: !skip,
|
|
993
|
+
error: void 0
|
|
994
|
+
});
|
|
995
|
+
const getClient = useWorkflowApiRef(api);
|
|
831
996
|
useEffect(() => {
|
|
832
|
-
if (
|
|
997
|
+
if (skip) return;
|
|
833
998
|
let cancelled = false;
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
999
|
+
getClient().list().then((workflows) => {
|
|
1000
|
+
if (!cancelled) setState({
|
|
1001
|
+
workflows,
|
|
1002
|
+
loading: false,
|
|
1003
|
+
error: void 0
|
|
1004
|
+
});
|
|
840
1005
|
}).catch((err) => {
|
|
841
1006
|
if (cancelled) return;
|
|
842
|
-
|
|
843
|
-
|
|
1007
|
+
setState({
|
|
1008
|
+
workflows: [],
|
|
1009
|
+
loading: false,
|
|
1010
|
+
error: errorMessage(err)
|
|
1011
|
+
});
|
|
844
1012
|
});
|
|
845
1013
|
return () => {
|
|
846
1014
|
cancelled = true;
|
|
847
1015
|
};
|
|
848
|
-
}, [
|
|
849
|
-
|
|
850
|
-
key,
|
|
851
|
-
workflow,
|
|
852
|
-
getClient
|
|
853
|
-
]);
|
|
854
|
-
return recovering;
|
|
1016
|
+
}, [skip, getClient]);
|
|
1017
|
+
return state;
|
|
855
1018
|
}
|
|
856
1019
|
//#endregion
|
|
857
|
-
//#region
|
|
1020
|
+
//#region src/components/workflow-fields.tsx
|
|
1021
|
+
/** @jsxImportSource react */
|
|
858
1022
|
/**
|
|
859
|
-
*
|
|
1023
|
+
* A form built from a workflow's declared input schema.
|
|
860
1024
|
*
|
|
861
|
-
* `
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
*
|
|
1025
|
+
* `GET workflows` reports each workflow's `inputSchema` as JSON Schema — the
|
|
1026
|
+
* zod schema an author wrote in `agent.ts`, converted at listing time precisely
|
|
1027
|
+
* so a browser can read it. This is what reads it: one `<WorkflowFields>` and a
|
|
1028
|
+
* workflow's form matches its schema by construction, so adding a field to the
|
|
1029
|
+
* schema adds it to the page and nothing can drift.
|
|
866
1030
|
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
*
|
|
871
|
-
*
|
|
1031
|
+
* ## It covers SCALARS, and says so rather than guessing
|
|
1032
|
+
*
|
|
1033
|
+
* A string, number, integer, boolean or enum has one obvious control each. A
|
|
1034
|
+
* nested object or an array does not — every choice (a JSON textarea, a repeater,
|
|
1035
|
+
* a comma-separated string) is a guess about what the author meant, and a guess
|
|
1036
|
+
* that produces a value the schema then rejects is worse than no field at all.
|
|
1037
|
+
* So those are SKIPPED, and the fields for them are written by hand: every field
|
|
1038
|
+
* in this package is a plain named control, so a hand-written one composes with
|
|
1039
|
+
* a generated one inside the same {@link Form}.
|
|
872
1040
|
*/
|
|
873
1041
|
/**
|
|
874
|
-
*
|
|
1042
|
+
* Render one field per scalar property of a workflow's input schema.
|
|
875
1043
|
*
|
|
876
|
-
*
|
|
877
|
-
* @
|
|
878
|
-
*
|
|
1044
|
+
* Pass the workflow's NAME and the schema is fetched here; pass a
|
|
1045
|
+
* {@link WorkflowSummary} you already hold and nothing is fetched. The name form
|
|
1046
|
+
* is the one a page usually wants — it is the same string the submit hook takes,
|
|
1047
|
+
* and the alternative is three lines (`useWorkflows()`, a `.find()` by name, and
|
|
1048
|
+
* folding that lookup's error into the form's) whose only product is this
|
|
1049
|
+
* component's argument.
|
|
879
1050
|
*
|
|
880
|
-
*
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
wake: useCallback(async () => {
|
|
885
|
-
if (runId === void 0) return 0;
|
|
886
|
-
return await getClient().wake(runId);
|
|
887
|
-
}, [runId, getClient]),
|
|
888
|
-
cancel: useCallback(async () => {
|
|
889
|
-
if (runId === void 0) return false;
|
|
890
|
-
return await getClient().cancel(runId);
|
|
891
|
-
}, [runId, getClient])
|
|
892
|
-
};
|
|
893
|
-
}
|
|
894
|
-
//#endregion
|
|
895
|
-
//#region _upload-recall.ts
|
|
896
|
-
/**
|
|
897
|
-
* Where an upload's ID survives a page RELOAD.
|
|
1051
|
+
* Renders nothing when the workflow declared no schema — a workflow with no
|
|
1052
|
+
* declared input takes anything, and a form for "anything" is not a form — and
|
|
1053
|
+
* nothing while a named lookup is still in flight, so the hand-written fields
|
|
1054
|
+
* beside it are not reordered when the schema lands.
|
|
898
1055
|
*
|
|
899
|
-
*
|
|
900
|
-
*
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
*
|
|
904
|
-
* were still in the store, the agent could still name them
|
|
905
|
-
* (`UploadInfo.ranges`), and the id was minted in the browser — and the browser
|
|
906
|
-
* had thrown away the only name for them. So a person who refreshed at 90% of a
|
|
907
|
-
* 200 MB recording sent the whole file again, which is the one interruption they
|
|
908
|
-
* are most likely to cause on purpose.
|
|
909
|
-
*
|
|
910
|
-
* This is that name, written down. It is what tus-js-client's `urlStorage` and
|
|
911
|
-
* Uppy's Golden Retriever sell, in the shape `session-resume-store.ts` already
|
|
912
|
-
* uses for a session id.
|
|
913
|
-
*
|
|
914
|
-
* ## A FINGERPRINT, because a `File` has no name a page can address
|
|
915
|
-
*
|
|
916
|
-
* A file from a picker carries no path and no handle, so the key is what
|
|
917
|
-
* tus-js-client fingerprints on: size, last-modified, type and name. Two
|
|
918
|
-
* different files agreeing on all four is the case this cannot tell apart — and
|
|
919
|
-
* the reason NOTHING here decides to resume. `_upload-files.ts` asks the agent
|
|
920
|
-
* what the id actually holds before sending a byte to it, so a wrong hit costs
|
|
921
|
-
* one `GET` and a fresh id rather than a corrupted upload.
|
|
922
|
-
*
|
|
923
|
-
* ## `sessionStorage`, deliberately
|
|
924
|
-
*
|
|
925
|
-
* The same call `session-resume-store.ts` makes, for a reason that happens to be
|
|
926
|
-
* stronger here: a reload and a same-tab navigation are exactly what this is for,
|
|
927
|
-
* and an id from yesterday names an upload the agent's sweep has very likely
|
|
928
|
-
* already collected. A tab is also the boundary the walk itself has — two tabs
|
|
929
|
-
* uploading the same recording are two submissions.
|
|
1056
|
+
* @example
|
|
1057
|
+
* ```tsx no-check
|
|
1058
|
+
* import { Form, SubmitButton, WorkflowFields, useWorkflowSubmit }
|
|
1059
|
+
* from "@alexkroman1/aai-ui";
|
|
1060
|
+
* import type { transcribe } from "./agent.ts";
|
|
930
1061
|
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
*
|
|
1062
|
+
* function StartRun() {
|
|
1063
|
+
* const { submitForm, pending, error } = useWorkflowSubmit<typeof transcribe>("transcribe");
|
|
1064
|
+
* return (
|
|
1065
|
+
* <Form onSubmit={submitForm} error={error}>
|
|
1066
|
+
* <WorkflowFields workflow="transcribe" />
|
|
1067
|
+
* <SubmitButton pending={pending}>Transcribe</SubmitButton>
|
|
1068
|
+
* </Form>
|
|
1069
|
+
* );
|
|
1070
|
+
* }
|
|
1071
|
+
* ```
|
|
938
1072
|
*
|
|
939
|
-
*
|
|
940
|
-
* tab and an entry is ~80 bytes. What it bounds is the long-lived tab that
|
|
941
|
-
* submits a hundred files: the oldest go first, and the id most likely to be
|
|
942
|
-
* worth resuming is the one written last.
|
|
943
|
-
*/
|
|
944
|
-
const MAX_REMEMBERED = 32;
|
|
945
|
-
/** One form's slot in storage. */
|
|
946
|
-
function keyFor(scope) {
|
|
947
|
-
return `${PREFIX$1}${scope}`;
|
|
948
|
-
}
|
|
949
|
-
/**
|
|
950
|
-
* What names this file across a reload.
|
|
1073
|
+
* @param props - Field-set props.
|
|
951
1074
|
*
|
|
952
|
-
*
|
|
953
|
-
* `name` last because it is the one a person can read in a debugger.
|
|
1075
|
+
* @public
|
|
954
1076
|
*/
|
|
955
|
-
function
|
|
956
|
-
|
|
1077
|
+
function WorkflowFields({ workflow }) {
|
|
1078
|
+
const { workflows, loading } = useWorkflows(typeof workflow === "string" ? {} : { skip: true });
|
|
1079
|
+
const summary = typeof workflow === "string" ? workflows.find((entry) => entry.name === workflow) : workflow;
|
|
1080
|
+
useDeclareFieldsPending(loading);
|
|
1081
|
+
const schema = asObjectSchema(summary?.inputSchema);
|
|
1082
|
+
if (!schema?.properties) return null;
|
|
1083
|
+
const required = new Set(schema.required ?? []);
|
|
1084
|
+
const uploads = new Set(summary?.uploads ?? []);
|
|
1085
|
+
return /* @__PURE__ */ jsx(Fragment, { children: Object.entries(schema.properties).map(([name, property]) => /* @__PURE__ */ jsx(SchemaField, {
|
|
1086
|
+
name,
|
|
1087
|
+
property,
|
|
1088
|
+
required: required.has(name),
|
|
1089
|
+
upload: uploads.has(name)
|
|
1090
|
+
}, name)) });
|
|
957
1091
|
}
|
|
958
|
-
/**
|
|
959
|
-
function
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1092
|
+
/** One property's control, or nothing when its type has no obvious one. */
|
|
1093
|
+
function SchemaField({ name, property, required, upload = false }) {
|
|
1094
|
+
const label = humanize(name);
|
|
1095
|
+
const hint = property.description === void 0 ? {} : { hint: property.description };
|
|
1096
|
+
const defaults = property.default === void 0 ? {} : { defaultValue: String(property.default) };
|
|
1097
|
+
if (upload) return /* @__PURE__ */ jsx(FileField, {
|
|
1098
|
+
name,
|
|
1099
|
+
label,
|
|
1100
|
+
required,
|
|
1101
|
+
upload: true,
|
|
1102
|
+
...hint
|
|
1103
|
+
});
|
|
1104
|
+
if (Array.isArray(property.enum) && property.enum.length > 0) return /* @__PURE__ */ jsx(SelectField, {
|
|
1105
|
+
name,
|
|
1106
|
+
label,
|
|
1107
|
+
required,
|
|
1108
|
+
options: property.enum.map((value) => String(value)),
|
|
1109
|
+
...hint,
|
|
1110
|
+
...defaults
|
|
1111
|
+
});
|
|
1112
|
+
const type = typeOf(property);
|
|
1113
|
+
switch (type) {
|
|
1114
|
+
case "boolean": return /* @__PURE__ */ jsx(CheckboxField, {
|
|
1115
|
+
name,
|
|
1116
|
+
label,
|
|
1117
|
+
defaultChecked: property.default === true,
|
|
1118
|
+
...hint
|
|
1119
|
+
});
|
|
1120
|
+
case "number":
|
|
1121
|
+
case "integer": return /* @__PURE__ */ jsx(NumberField, {
|
|
1122
|
+
name,
|
|
1123
|
+
label,
|
|
1124
|
+
required,
|
|
1125
|
+
step: type === "integer" ? 1 : "any",
|
|
1126
|
+
...hint,
|
|
1127
|
+
...defaults
|
|
1128
|
+
});
|
|
1129
|
+
case "string": return /* @__PURE__ */ jsx(TextField, {
|
|
1130
|
+
name,
|
|
1131
|
+
label,
|
|
1132
|
+
required,
|
|
1133
|
+
...hint,
|
|
1134
|
+
...defaults
|
|
1135
|
+
});
|
|
1136
|
+
default: return null;
|
|
967
1137
|
}
|
|
968
1138
|
}
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1139
|
+
/** A property's type, taking the first non-null member of a union. */
|
|
1140
|
+
function typeOf(property) {
|
|
1141
|
+
const { type } = property;
|
|
1142
|
+
if (typeof type === "string") return type;
|
|
1143
|
+
return Array.isArray(type) ? type.find((member) => member !== "null") : void 0;
|
|
1144
|
+
}
|
|
1145
|
+
/** The listing's `unknown` schema as the object shape this reads, when it is one. */
|
|
1146
|
+
function asObjectSchema(schema) {
|
|
1147
|
+
return isRecord(schema) ? schema : void 0;
|
|
973
1148
|
}
|
|
974
1149
|
/**
|
|
975
|
-
*
|
|
976
|
-
*
|
|
977
|
-
* A hit is a CANDIDATE and never a decision — see the module doc.
|
|
1150
|
+
* A property name as a label — `recordingId` → `Recording id`.
|
|
978
1151
|
*
|
|
979
|
-
*
|
|
1152
|
+
* A default, not a policy: a schema whose labels matter should carry a
|
|
1153
|
+
* `.describe()`, and an author who wants exact control writes the field.
|
|
980
1154
|
*/
|
|
981
|
-
function
|
|
982
|
-
const
|
|
983
|
-
return
|
|
1155
|
+
function humanize(name) {
|
|
1156
|
+
const spaced = name.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim().toLowerCase();
|
|
1157
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
984
1158
|
}
|
|
1159
|
+
//#endregion
|
|
1160
|
+
//#region src/components/workflow-progress.tsx
|
|
1161
|
+
/** @jsxImportSource react */
|
|
985
1162
|
/**
|
|
986
|
-
*
|
|
1163
|
+
* What a run has said so far, rendered.
|
|
987
1164
|
*
|
|
988
|
-
*
|
|
989
|
-
*
|
|
990
|
-
*
|
|
1165
|
+
* The complement of a status line, and the reason both exist: a run is
|
|
1166
|
+
* `running` for its whole life, so a one-round job and a ten-round one look
|
|
1167
|
+
* identical while they happen. These lines come from the run itself (`stepReport()`
|
|
1168
|
+
* in a `"use step"` body), which is the only channel a workflow has before it
|
|
1169
|
+
* produces an output.
|
|
991
1170
|
*
|
|
992
|
-
*
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
const entries = read(scope);
|
|
996
|
-
const key = fingerprint(file);
|
|
997
|
-
delete entries[key];
|
|
998
|
-
entries[key] = id;
|
|
999
|
-
const keys = Object.keys(entries);
|
|
1000
|
-
for (const stale of keys.slice(0, Math.max(0, keys.length - MAX_REMEMBERED))) delete entries[stale];
|
|
1001
|
-
write(scope, entries);
|
|
1002
|
-
}
|
|
1003
|
-
/**
|
|
1004
|
-
* Forget it: the agent holds nothing resumable under this id.
|
|
1171
|
+
* Four rules are baked in, and they are why this is a component rather than
|
|
1172
|
+
* three lines each page writes for itself — the two templates that had written
|
|
1173
|
+
* it had written three of them, comments included:
|
|
1005
1174
|
*
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
1008
|
-
*
|
|
1175
|
+
* - **It renders nothing until there is something to render.** `supported` is
|
|
1176
|
+
* what keeps this from being an empty box forever on an agent deployed before
|
|
1177
|
+
* progress streams existed: "wrote nothing yet" and "serves no stream" are
|
|
1178
|
+
* indistinguishable from the chunk list alone.
|
|
1179
|
+
* - **The lines are TEXT, not elements.** They are append-only and two rounds
|
|
1180
|
+
* legitimately produce identical text, so there is no stable per-line key to
|
|
1181
|
+
* give React. Joining sidesteps the question instead of suppressing the lint
|
|
1182
|
+
* rule that asks it.
|
|
1183
|
+
* - **They REPLAY.** Chunks are retained with the run, so a reload mid-run —
|
|
1184
|
+
* or opening a finished run tomorrow — shows how it got there rather than an
|
|
1185
|
+
* empty box. That is `useWorkflowProgress`'s doing; this is what makes it
|
|
1186
|
+
* visible.
|
|
1187
|
+
* - **They are ANNOUNCED**, for the reason the first paragraph gives: this is
|
|
1188
|
+
* the only channel a run has before it produces an output, and a `<pre>` that
|
|
1189
|
+
* grows is a silent one. A screen-reader user pressing "Digest" got nothing
|
|
1190
|
+
* between the click and a terminal state minutes later — no "fetching", no
|
|
1191
|
+
* "summarising", no evidence the button did anything. See `role="log"` below.
|
|
1192
|
+
* The six pages that render this pass only `className`, so no template could
|
|
1193
|
+
* have fixed it locally; that is what makes it this component's job.
|
|
1009
1194
|
*
|
|
1010
|
-
* @
|
|
1195
|
+
* @example
|
|
1196
|
+
* ```tsx
|
|
1197
|
+
* import { WorkflowProgress } from "@alexkroman1/aai-ui";
|
|
1198
|
+
*
|
|
1199
|
+
* function RunPanel({ runId }: { runId: string }) {
|
|
1200
|
+
* return <WorkflowProgress runId={runId} />;
|
|
1201
|
+
* }
|
|
1202
|
+
* ```
|
|
1203
|
+
*
|
|
1204
|
+
* @param props - Progress-log props.
|
|
1205
|
+
*
|
|
1206
|
+
* @public
|
|
1011
1207
|
*/
|
|
1012
|
-
function
|
|
1013
|
-
const
|
|
1014
|
-
const
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1208
|
+
function WorkflowProgress({ runId, api, className, placeholder, lines }) {
|
|
1209
|
+
const { progress, streaming, supported } = useWorkflowProgress(runId, omitUndefined({ api }));
|
|
1210
|
+
const text = useMemo(() => {
|
|
1211
|
+
const shown = lines === void 0 ? progress : progress.slice(Math.max(progress.length - lines, 0));
|
|
1212
|
+
return shown.length === 0 ? null : shown.join("\n");
|
|
1213
|
+
}, [progress, lines]);
|
|
1214
|
+
if (!supported || text === null) return placeholder ?? null;
|
|
1215
|
+
return /* @__PURE__ */ jsxs("pre", {
|
|
1216
|
+
role: "log",
|
|
1217
|
+
"aria-live": "polite",
|
|
1218
|
+
"aria-atomic": "false",
|
|
1219
|
+
className: clsx(className ?? "whitespace-pre-wrap border-l pl-4 text-xs opacity-70"),
|
|
1220
|
+
children: [text, streaming && "\n…"]
|
|
1221
|
+
});
|
|
1018
1222
|
}
|
|
1019
1223
|
//#endregion
|
|
1020
|
-
//#region
|
|
1021
|
-
/**
|
|
1022
|
-
function isAbortError(err) {
|
|
1023
|
-
return err instanceof Error && err.name === "AbortError";
|
|
1024
|
-
}
|
|
1025
|
-
/** A fresh upload id: a capability, so it is random rather than derived. */
|
|
1026
|
-
function randomUploadId() {
|
|
1027
|
-
return crypto.randomUUID().replaceAll("-", "");
|
|
1028
|
-
}
|
|
1224
|
+
//#region src/page.tsx
|
|
1225
|
+
/** @jsxImportSource react */
|
|
1029
1226
|
/**
|
|
1030
|
-
*
|
|
1227
|
+
* `mountPage()` — mount a WORKFLOW APP's UI: React, theme, no session.
|
|
1031
1228
|
*
|
|
1032
|
-
* The
|
|
1033
|
-
*
|
|
1034
|
-
*
|
|
1035
|
-
*
|
|
1036
|
-
*
|
|
1229
|
+
* The twin of `mountClient()` for an agent whose front door is a form rather than a
|
|
1230
|
+
* microphone (`workflowApp()`). It is a separate entry rather than
|
|
1231
|
+
* an option on `mountClient()` because of what `mountClient()` unavoidably does: it
|
|
1232
|
+
* constructs a `BrowserSession`, which owns a WebSocket URL provider, an audio
|
|
1233
|
+
* graph, and a microphone request. A flag would have to make all of that
|
|
1234
|
+
* conditional, and every session hook would then have to answer "what does this
|
|
1235
|
+
* mean with no session?" — so the honest split is two mounts. A page that wants
|
|
1236
|
+
* voice uses `mountClient()`; a page that wants neither audio nor a socket uses this.
|
|
1037
1237
|
*
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1238
|
+
* Authoring is otherwise identical — the file is still `client.tsx`, still
|
|
1239
|
+
* React, still Tailwind, still the same theme tokens — so a workflow app reads
|
|
1240
|
+
* like every other agent. What it reaches for instead of `useSession()` is
|
|
1241
|
+
* `createWorkflowApi()` / `useWorkflowRun()`.
|
|
1041
1242
|
*/
|
|
1042
|
-
async function sendThroughGate(gate, send) {
|
|
1043
|
-
let tried = false;
|
|
1044
|
-
for (;;) {
|
|
1045
|
-
await gate.settle();
|
|
1046
|
-
if (gate.cancelled) throw new Error("Upload cancelled.");
|
|
1047
|
-
const resume = tried;
|
|
1048
|
-
tried = true;
|
|
1049
|
-
try {
|
|
1050
|
-
await send(resume);
|
|
1051
|
-
return;
|
|
1052
|
-
} catch (err) {
|
|
1053
|
-
if (gate.cancelled || !isAbortError(err)) throw err;
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
1243
|
/**
|
|
1058
|
-
*
|
|
1244
|
+
* Mount a page for an agent whose work happens in workflows.
|
|
1059
1245
|
*
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
|
|
1063
|
-
function createUploadGate() {
|
|
1064
|
-
let controller = new AbortController();
|
|
1065
|
-
let paused = false;
|
|
1066
|
-
let cancelled = false;
|
|
1067
|
-
let open;
|
|
1068
|
-
let closed;
|
|
1069
|
-
return {
|
|
1070
|
-
get paused() {
|
|
1071
|
-
return paused;
|
|
1072
|
-
},
|
|
1073
|
-
get cancelled() {
|
|
1074
|
-
return cancelled;
|
|
1075
|
-
},
|
|
1076
|
-
get signal() {
|
|
1077
|
-
return controller.signal;
|
|
1078
|
-
},
|
|
1079
|
-
pause() {
|
|
1080
|
-
if (paused || cancelled) return;
|
|
1081
|
-
paused = true;
|
|
1082
|
-
const gate = Promise.withResolvers();
|
|
1083
|
-
closed = gate.promise;
|
|
1084
|
-
open = gate.resolve;
|
|
1085
|
-
controller.abort();
|
|
1086
|
-
},
|
|
1087
|
-
resume() {
|
|
1088
|
-
if (!paused || cancelled) return;
|
|
1089
|
-
paused = false;
|
|
1090
|
-
controller = new AbortController();
|
|
1091
|
-
open?.();
|
|
1092
|
-
open = void 0;
|
|
1093
|
-
closed = void 0;
|
|
1094
|
-
},
|
|
1095
|
-
cancel() {
|
|
1096
|
-
if (cancelled) return;
|
|
1097
|
-
cancelled = true;
|
|
1098
|
-
paused = false;
|
|
1099
|
-
controller.abort();
|
|
1100
|
-
open?.();
|
|
1101
|
-
open = void 0;
|
|
1102
|
-
closed = void 0;
|
|
1103
|
-
},
|
|
1104
|
-
async settle() {
|
|
1105
|
-
if (closed) await closed;
|
|
1106
|
-
}
|
|
1107
|
-
};
|
|
1108
|
-
}
|
|
1109
|
-
//#endregion
|
|
1110
|
-
//#region _workflow-files.ts
|
|
1111
|
-
/**
|
|
1112
|
-
* Which of a submitted form's values are FILES.
|
|
1246
|
+
* There is deliberately no session, no microphone, and no socket: the component
|
|
1247
|
+
* talks to the agent over the workflow HTTP API
|
|
1248
|
+
* (`createWorkflowApi`/`useWorkflowRun`), which is durable and outlives the tab.
|
|
1113
1249
|
*
|
|
1114
|
-
*
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1118
|
-
* file and the other does not, which is invisible until the run reads the wrong
|
|
1119
|
-
* kind of string.
|
|
1120
|
-
*/
|
|
1121
|
-
/**
|
|
1122
|
-
* The files a submitted field carries, if that is what it carries.
|
|
1250
|
+
* @example
|
|
1251
|
+
* ```tsx
|
|
1252
|
+
* import { createWorkflowApi, mountPage, useWorkflowRun } from "@alexkroman1/aai-ui";
|
|
1253
|
+
* import { useState } from "react";
|
|
1123
1254
|
*
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
1126
|
-
* into ids would corrupt it silently.
|
|
1127
|
-
*/
|
|
1128
|
-
function filesOf(value) {
|
|
1129
|
-
if (value instanceof File) return [value];
|
|
1130
|
-
if (!Array.isArray(value)) return [];
|
|
1131
|
-
const files = value.filter((one) => one instanceof File);
|
|
1132
|
-
return files.length > 0 && files.length === value.length ? files : [];
|
|
1133
|
-
}
|
|
1134
|
-
/**
|
|
1135
|
-
* The input properties still carrying a `File` — i.e. the ones that CANNOT survive
|
|
1136
|
-
* being sent.
|
|
1255
|
+
* // Hoisted: a client built in render is a new object every render.
|
|
1256
|
+
* const api = createWorkflowApi();
|
|
1137
1257
|
*
|
|
1138
|
-
*
|
|
1139
|
-
*
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
1142
|
-
*
|
|
1143
|
-
*
|
|
1258
|
+
* function App() {
|
|
1259
|
+
* const [runId, setRunId] = useState<string>();
|
|
1260
|
+
* const { run } = useWorkflowRun(runId, { api });
|
|
1261
|
+
* return (
|
|
1262
|
+
* <button
|
|
1263
|
+
* type="button"
|
|
1264
|
+
* onClick={() => void api.start("digest", { topic: "ai" }).then(setRunId)}
|
|
1265
|
+
* >
|
|
1266
|
+
* {run ? run.status : "Start"}
|
|
1267
|
+
* </button>
|
|
1268
|
+
* );
|
|
1269
|
+
* }
|
|
1144
1270
|
*
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
1147
|
-
* were missed. Both hooks are the callers.
|
|
1148
|
-
*/
|
|
1149
|
-
function fileFields(input) {
|
|
1150
|
-
if (!isRecord(input)) return [];
|
|
1151
|
-
return Object.entries(input).filter(([, value]) => filesOf(value).length > 0).map(([key]) => key);
|
|
1152
|
-
}
|
|
1153
|
-
//#endregion
|
|
1154
|
-
//#region _upload-files.ts
|
|
1155
|
-
/**
|
|
1156
|
-
* Turning a form's `File`s into stored upload ids, pauses and all.
|
|
1271
|
+
* mountPage({ name: "Digest", component: App });
|
|
1272
|
+
* ```
|
|
1157
1273
|
*
|
|
1158
|
-
*
|
|
1159
|
-
* real one: that module is the two HOOKS and the state between them, where this
|
|
1160
|
-
* is the walk over a submitted input — which is the only part of it that knows
|
|
1161
|
-
* what a `File` is, holds a loop, and survives being re-entered.
|
|
1274
|
+
* @throws If the target element is not found in the DOM.
|
|
1162
1275
|
*
|
|
1163
|
-
*
|
|
1164
|
-
* one file rather than walking an input and shares only the gate underneath both
|
|
1165
|
-
* (`_upload-session.ts`).
|
|
1276
|
+
* @public
|
|
1166
1277
|
*/
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
ids: /* @__PURE__ */ new Map(),
|
|
1172
|
-
stored: /* @__PURE__ */ new Map(),
|
|
1173
|
-
tried: /* @__PURE__ */ new Set(),
|
|
1174
|
-
gate: createUploadGate()
|
|
1175
|
-
};
|
|
1278
|
+
function mountPage(config) {
|
|
1279
|
+
const container = resolveContainer(config.target);
|
|
1280
|
+
setPageTitle(config.name);
|
|
1281
|
+
return mountRoot(container, createElement(ThemeProvider, { value: config.theme }, createElement(config.component)));
|
|
1176
1282
|
}
|
|
1283
|
+
//#endregion
|
|
1284
|
+
//#region src/use-download-url.ts
|
|
1177
1285
|
/**
|
|
1178
|
-
*
|
|
1179
|
-
*
|
|
1286
|
+
* `useDownloadUrl` — an upload id a run produced, as something `<audio>`,
|
|
1287
|
+
* `<img>` or `<a download>` will accept.
|
|
1180
1288
|
*
|
|
1181
|
-
*
|
|
1182
|
-
*
|
|
1183
|
-
*
|
|
1184
|
-
*
|
|
1289
|
+
* `api.download(id)` resolves a `Blob`, and it has to: the byte route takes the
|
|
1290
|
+
* same bearer every workflow route does, and neither `<audio src>` nor
|
|
1291
|
+
* `<a href>` can send one. So every page that plays back what a run WROTE ends
|
|
1292
|
+
* up at the same four lines — `download` → `createObjectURL` → state — and the
|
|
1293
|
+
* two that are really the point are the two the four lines are wrapped in:
|
|
1185
1294
|
*
|
|
1186
|
-
*
|
|
1295
|
+
* - **`URL.revokeObjectURL` on cleanup.** An object URL pins its blob for the
|
|
1296
|
+
* life of the DOCUMENT. Miss it and every completed run's audio stays resident
|
|
1297
|
+
* until the tab closes, which on a page people run all day is a leak measured
|
|
1298
|
+
* in the size of the files.
|
|
1299
|
+
* - **A `cancelled` flag.** A second run settling while the first download is
|
|
1300
|
+
* still in flight otherwise sets state from the stale one, and the page plays
|
|
1301
|
+
* the previous run's audio under the current run's transcript — a wrong answer
|
|
1302
|
+
* that looks like a right one.
|
|
1187
1303
|
*
|
|
1188
|
-
*
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
1191
|
-
*
|
|
1192
|
-
* upload resumable at all, so the id is reused and the attempt claims it.
|
|
1193
|
-
* - **Anything else.** A 404 (swept, or never seen), a failure, or an unfinished
|
|
1194
|
-
* upload reporting NO windows — which is a partial single `PUT`, and a second
|
|
1195
|
-
* `PUT` to that id is a 409 rather than an append (`streamUploadFile`). Reusing
|
|
1196
|
-
* it would turn a reload into a failure the person cannot clear, so the entry is
|
|
1197
|
-
* dropped and the file gets a fresh id.
|
|
1304
|
+
* Two templates had written this hook, identically, doc paragraph included, and
|
|
1305
|
+
* `aai-ui` exported no download helper at all. Both also faked `pending` by
|
|
1306
|
+
* checking `url === undefined && error === undefined`, which reads "idle" and
|
|
1307
|
+
* "downloading" as the same thing — so this reports it.
|
|
1198
1308
|
*/
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
complete: false
|
|
1204
|
-
};
|
|
1205
|
-
const info = await api.uploadInfo(remembered).catch(() => void 0);
|
|
1206
|
-
if (info?.complete === true) return {
|
|
1207
|
-
id: remembered,
|
|
1208
|
-
complete: true
|
|
1209
|
-
};
|
|
1210
|
-
if (info !== void 0 && (info.ranges?.length ?? 0) > 0) {
|
|
1211
|
-
session.tried.add(file);
|
|
1212
|
-
return {
|
|
1213
|
-
id: remembered,
|
|
1214
|
-
complete: false
|
|
1215
|
-
};
|
|
1216
|
-
}
|
|
1217
|
-
forgetUploadId(session.scope, file);
|
|
1218
|
-
return {
|
|
1219
|
-
id: randomUploadId(),
|
|
1220
|
-
complete: false
|
|
1221
|
-
};
|
|
1222
|
-
}
|
|
1309
|
+
/** No id: nothing pending, nothing to show. A shared object so `setState` no-ops. */
|
|
1310
|
+
const IDLE = { pending: false };
|
|
1311
|
+
/** Bytes in flight. Shared for the same reason as {@link IDLE}. */
|
|
1312
|
+
const PENDING = { pending: true };
|
|
1223
1313
|
/**
|
|
1224
|
-
*
|
|
1225
|
-
* reporting how far each one has got.
|
|
1226
|
-
*
|
|
1227
|
-
* Sequential rather than `Promise.all`: these are large bodies, and a form with
|
|
1228
|
-
* two 200 MB recordings should send them one after another rather than compete
|
|
1229
|
-
* for the same connection. That is also what makes a single bar honest — one
|
|
1230
|
-
* file is in flight at a time, and `index`/`count` say which.
|
|
1314
|
+
* Read an upload's bytes and hand back a URL a DOM element can use.
|
|
1231
1315
|
*
|
|
1232
|
-
*
|
|
1233
|
-
*
|
|
1234
|
-
*
|
|
1316
|
+
* @param uploadId - The id a completed run reported, or `undefined` before one
|
|
1317
|
+
* exists — which is what a page passes straight through while it waits, and
|
|
1318
|
+
* reports as idle rather than pending.
|
|
1319
|
+
* @param opts - See {@link UseDownloadUrlOptions}.
|
|
1320
|
+
* @returns See {@link UseDownloadUrlResult}.
|
|
1235
1321
|
*
|
|
1236
|
-
*
|
|
1322
|
+
* @example
|
|
1323
|
+
* ```tsx no-check
|
|
1324
|
+
* import { useDownloadUrl, useWorkflowSubmit } from "@alexkroman1/aai-ui";
|
|
1325
|
+
* import type { spokenSummary } from "./agent.ts";
|
|
1237
1326
|
*
|
|
1238
|
-
*
|
|
1239
|
-
*
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1242
|
-
*
|
|
1243
|
-
*
|
|
1327
|
+
* function Playback() {
|
|
1328
|
+
* const { run } = useWorkflowSubmit<typeof spokenSummary>("spokenSummary");
|
|
1329
|
+
* const output = run?.status === "completed" ? run.output : undefined;
|
|
1330
|
+
* const audio = useDownloadUrl(output?.audio);
|
|
1331
|
+
* if (audio.pending) return <p>Fetching audio…</p>;
|
|
1332
|
+
* if (audio.error !== undefined) return <p role="alert">{audio.error}</p>;
|
|
1333
|
+
* return audio.url === undefined ? null : (
|
|
1334
|
+
* <a href={audio.url} download="summary.mp3">
|
|
1335
|
+
* Download
|
|
1336
|
+
* </a>
|
|
1337
|
+
* );
|
|
1338
|
+
* }
|
|
1339
|
+
* ```
|
|
1244
1340
|
*
|
|
1245
|
-
*
|
|
1246
|
-
* last byte lands, so the incomplete record a streamed upload leaves along the
|
|
1247
|
-
* way is one nobody reads.
|
|
1341
|
+
* @public
|
|
1248
1342
|
*/
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
const
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
const done = session.stored.get(file);
|
|
1257
|
-
if (done !== void 0) return done;
|
|
1258
|
-
const position = {
|
|
1259
|
-
name: file.name,
|
|
1260
|
-
index,
|
|
1261
|
-
count
|
|
1262
|
-
};
|
|
1263
|
-
const known = session.ids.get(file);
|
|
1264
|
-
const claimed = known === void 0 ? await claimId(api, session, file) : {
|
|
1265
|
-
id: known,
|
|
1266
|
-
complete: false
|
|
1267
|
-
};
|
|
1268
|
-
const id = claimed.id;
|
|
1269
|
-
if (known === void 0) {
|
|
1270
|
-
session.ids.set(file, id);
|
|
1271
|
-
rememberUploadId(session.scope, file, id);
|
|
1343
|
+
function useDownloadUrl(uploadId, opts = {}) {
|
|
1344
|
+
const [state, setState] = useState(IDLE);
|
|
1345
|
+
const getClient = useWorkflowApiRef(opts.api);
|
|
1346
|
+
useEffect(() => {
|
|
1347
|
+
if (uploadId === void 0) {
|
|
1348
|
+
setState(IDLE);
|
|
1349
|
+
return;
|
|
1272
1350
|
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1351
|
+
let cancelled = false;
|
|
1352
|
+
let objectUrl;
|
|
1353
|
+
setState(PENDING);
|
|
1354
|
+
getClient().download(uploadId).then((blob) => {
|
|
1355
|
+
if (cancelled) return;
|
|
1356
|
+
objectUrl = URL.createObjectURL(blob);
|
|
1357
|
+
setState({
|
|
1358
|
+
url: objectUrl,
|
|
1359
|
+
pending: false
|
|
1280
1360
|
});
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
await api.uploadStream(id, file, {
|
|
1286
|
-
name: file.name,
|
|
1287
|
-
signal: session.gate.signal,
|
|
1288
|
-
onProgress: (progress) => report({
|
|
1289
|
-
...position,
|
|
1290
|
-
...progress,
|
|
1291
|
-
paused: session.gate.paused
|
|
1292
|
-
}),
|
|
1293
|
-
...omitUndefined({
|
|
1294
|
-
parallel,
|
|
1295
|
-
resume: resume || session.tried.has(file) ? true : void 0
|
|
1296
|
-
})
|
|
1361
|
+
}).catch((err) => {
|
|
1362
|
+
if (!cancelled) setState({
|
|
1363
|
+
error: errorMessage(err),
|
|
1364
|
+
pending: false
|
|
1297
1365
|
});
|
|
1298
1366
|
});
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
out[name] = await store(value);
|
|
1306
|
-
continue;
|
|
1307
|
-
}
|
|
1308
|
-
const chosen = filesOf(value);
|
|
1309
|
-
if (chosen.length === 0) {
|
|
1310
|
-
out[name] = value;
|
|
1311
|
-
continue;
|
|
1312
|
-
}
|
|
1313
|
-
const ids = [];
|
|
1314
|
-
for (const file of chosen) ids.push(await store(file));
|
|
1315
|
-
out[name] = ids;
|
|
1316
|
-
}
|
|
1317
|
-
return out;
|
|
1367
|
+
return () => {
|
|
1368
|
+
cancelled = true;
|
|
1369
|
+
if (objectUrl !== void 0) URL.revokeObjectURL(objectUrl);
|
|
1370
|
+
};
|
|
1371
|
+
}, [uploadId, getClient]);
|
|
1372
|
+
return state;
|
|
1318
1373
|
}
|
|
1319
1374
|
//#endregion
|
|
1320
|
-
//#region
|
|
1375
|
+
//#region src/use-run-key.ts
|
|
1321
1376
|
/**
|
|
1322
|
-
* The
|
|
1323
|
-
* submission is currently holding.
|
|
1377
|
+
* The handle a page keeps on the runs it started, across a reload.
|
|
1324
1378
|
*
|
|
1325
|
-
* `useWorkflowSubmit`
|
|
1326
|
-
*
|
|
1327
|
-
*
|
|
1328
|
-
*
|
|
1329
|
-
*
|
|
1330
|
-
*
|
|
1379
|
+
* `useWorkflowSubmit` is what makes a run survivable — the run id is that
|
|
1380
|
+
* hook's own state, so a refresh loses it while the run carries on — and the
|
|
1381
|
+
* `key` is what it looks the run up BY, because it is a lookup CAPABILITY:
|
|
1382
|
+
* there is no per-user filtering behind `find`, so the key IS the scoping
|
|
1383
|
+
* mechanism. Choosing one is easy to get wrong in three separate ways, and six
|
|
1384
|
+
* shipped templates had each written the same twenty lines to get it right.
|
|
1385
|
+
* This is those lines.
|
|
1386
|
+
*
|
|
1387
|
+
* **`useWorkflowSubmit` now mints one for itself** ({@link useDefaultRunKey}),
|
|
1388
|
+
* so a page resumes its own run across a reload with nothing written at the
|
|
1389
|
+
* call site — six of six page templates passed `useRunKey()` and
|
|
1390
|
+
* `recover: true`, which is a default in the wrong place. The hook stays
|
|
1391
|
+
* PUBLIC for the page that wants to choose: an app with accounts passes the
|
|
1392
|
+
* ACCOUNT's own id instead, and a run then follows the person to a new device,
|
|
1393
|
+
* which is a promise only a login can keep; a page whose run outlives the tab
|
|
1394
|
+
* passes `useRunKey({ storage: "local" })`.
|
|
1395
|
+
*
|
|
1396
|
+
* ## Three properties, and the rejected alternatives are why each one matters
|
|
1397
|
+
*
|
|
1398
|
+
* - **Opaque** — `crypto.randomUUID()`, never derived from what was submitted.
|
|
1399
|
+
* A key derived from the input collides the moment two people submit the same
|
|
1400
|
+
* thing, and they then recover each other's runs; it also carries what they
|
|
1401
|
+
* typed into a lookup token the platform deliberately stopped logging.
|
|
1402
|
+
* - **Short.** A `randomUUID` is 36 characters, well inside the 256 that
|
|
1403
|
+
* `POST /workflows/runs` allows a key.
|
|
1404
|
+
* - **Minted once per load and written back for the next one**, which is the
|
|
1405
|
+
* whole mechanism: the load that presses the button records the key with the
|
|
1406
|
+
* run, and the load after it finds the run by producing the same key.
|
|
1407
|
+
*
|
|
1408
|
+
* Storage rather than the page's own URL, for all of them. A `?key=` parameter
|
|
1409
|
+
* survives more (a new tab, a bookmark, a shared link) and that is the problem:
|
|
1410
|
+
* a URL is pasted into chats, copied into referrers and kept in history, and
|
|
1411
|
+
* what a leaked one buys is somebody else's work — reading it, and `cancel()` on
|
|
1412
|
+
* it. An app with accounts should pass the ACCOUNT's own id here instead, and
|
|
1413
|
+
* then a run follows the person to a new device, which is a promise only a login
|
|
1414
|
+
* can keep.
|
|
1415
|
+
*
|
|
1416
|
+
* ## The storage is the caller's decision, and it is not a detail
|
|
1417
|
+
*
|
|
1418
|
+
* `"session"` (the default) dies with the tab, which covers exactly the
|
|
1419
|
+
* interruption most pages have — a reload, a same-tab navigation, a crashed tab
|
|
1420
|
+
* — and is the same lifetime as this package's other two stores, the session
|
|
1421
|
+
* resume id (`session-resume-store.ts`) and the upload recall
|
|
1422
|
+
* (`_upload-recall.ts`), so both halves of a reload make the same promise.
|
|
1423
|
+
*
|
|
1424
|
+
* `"local"` is for a run that outlives all of that BY DESIGN — one that sleeps
|
|
1425
|
+
* between digests and may live a month, where closing the browser on Tuesday and
|
|
1426
|
+
* coming back on Friday to press Stop is the ordinary case rather than an edge
|
|
1427
|
+
* one, and a tab-scoped key would answer that with an empty form beside a run
|
|
1428
|
+
* still posting somewhere. It is as far as a key can go without a login, and no
|
|
1429
|
+
* further. `podcast-digest` is that template, and the reason this hook is still
|
|
1430
|
+
* called by name anywhere; the other five take the tab-scoped default the
|
|
1431
|
+
* submit hook mints for them.
|
|
1432
|
+
*
|
|
1433
|
+
* ## Anything ELSE a page stores back must be VALIDATED on read
|
|
1434
|
+
*
|
|
1435
|
+
* This key needs no validation, and it is worth saying why, because it is the
|
|
1436
|
+
* exception: any string is a legal key, so a value from storage can only fail to
|
|
1437
|
+
* match a run. A page that remembers something more — which MODE submitted, say
|
|
1438
|
+
* — is remembering a value it will turn into a name, and storage hands back a
|
|
1439
|
+
* string some earlier version of that page wrote: a renamed mode, a hand-edited
|
|
1440
|
+
* value, a slot another app on the origin happens to share. Unchecked, that
|
|
1441
|
+
* starts a run called `undefined` and answers a 400 nobody typed. Check it
|
|
1442
|
+
* against the page's own list on the way out (`recalledMode` in
|
|
1443
|
+
* `transcription-workflow/recover.ts` is the worked example) — the recall is the
|
|
1444
|
+
* page's, the validation is not optional.
|
|
1445
|
+
*
|
|
1446
|
+
* ## The slot is keyed by the page's own URL
|
|
1447
|
+
*
|
|
1448
|
+
* Every deployed agent is served from one origin at `/:slug/`, so a fixed name
|
|
1449
|
+
* would have two agents scaffolded from the same template recover each other's
|
|
1450
|
+
* runs. The key is the page's own directory — resolved through `"./"`, which
|
|
1451
|
+
* drops the query and the hash, since a reload carrying `?foo` or `#bar` has to
|
|
1452
|
+
* find the same key. Same call `session-resume-store.ts` makes, for the same
|
|
1453
|
+
* reason.
|
|
1454
|
+
*
|
|
1455
|
+
* One key per PAGE is right even for a page driving several workflows: `find` is
|
|
1456
|
+
* scoped by workflow as well as by key, so three hooks sharing one key recover
|
|
1457
|
+
* three separate runs. `transcription-workflow` is that page.
|
|
1458
|
+
*
|
|
1459
|
+
* Every access is guarded. Storage THROWS outright in some contexts (Safari
|
|
1460
|
+
* private mode, an iframe blocked by policy) and is ABSENT in others (any
|
|
1461
|
+
* server-side render), and a page that cannot remember its key must degrade to
|
|
1462
|
+
* the behaviour it would have had anyway — one run per load — rather than
|
|
1463
|
+
* failing to render.
|
|
1331
1464
|
*/
|
|
1465
|
+
/** Where a run key lives, namespaced like this package's two other stores. */
|
|
1466
|
+
const PREFIX$1 = "aai:run-key:";
|
|
1467
|
+
/** This page's own slot — see "The slot is keyed by the page's own URL". */
|
|
1468
|
+
function slotFor() {
|
|
1469
|
+
return urlSlot(PREFIX$1, "./");
|
|
1470
|
+
}
|
|
1332
1471
|
/**
|
|
1333
|
-
*
|
|
1334
|
-
*
|
|
1335
|
-
* @param getGate - Reads the gate out of the caller's ref, so the callbacks stay
|
|
1336
|
-
* stable across submissions rather than being re-created per gate.
|
|
1337
|
-
* @param setUpload - The status setter the progress bar renders from.
|
|
1472
|
+
* Read the key this page already has, or mint and remember one.
|
|
1338
1473
|
*
|
|
1339
|
-
*
|
|
1474
|
+
* Not exported: a page that wants a key wants it for the life of a component,
|
|
1475
|
+
* which is what the hook is. Calling this per render would mint a fresh key and
|
|
1476
|
+
* hand `recover` one nothing was ever started under.
|
|
1340
1477
|
*/
|
|
1341
|
-
function
|
|
1342
|
-
const
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
return {
|
|
1349
|
-
pauseUpload: useCallback(() => {
|
|
1350
|
-
getGate()?.pause();
|
|
1351
|
-
setPaused(true);
|
|
1352
|
-
}, [getGate, setPaused]),
|
|
1353
|
-
resumeUpload: useCallback(() => {
|
|
1354
|
-
getGate()?.resume();
|
|
1355
|
-
setPaused(false);
|
|
1356
|
-
}, [getGate, setPaused])
|
|
1357
|
-
};
|
|
1478
|
+
function mintRunKey(storage) {
|
|
1479
|
+
const slot = slotFor();
|
|
1480
|
+
const stored = storageGet(storage, slot);
|
|
1481
|
+
if (stored !== void 0) return stored;
|
|
1482
|
+
const minted = crypto.randomUUID();
|
|
1483
|
+
storageSet(storage, slot, minted);
|
|
1484
|
+
return minted;
|
|
1358
1485
|
}
|
|
1359
|
-
//#endregion
|
|
1360
|
-
//#region use-workflow-form.ts
|
|
1361
1486
|
/**
|
|
1362
|
-
* The
|
|
1487
|
+
* The key `useWorkflowSubmit` uses when the page named none.
|
|
1363
1488
|
*
|
|
1364
|
-
* `
|
|
1365
|
-
*
|
|
1366
|
-
* workflows so `<WorkflowFields>` can render a form from a schema, and
|
|
1367
|
-
* `useWorkflowSubmit` starts a run and hands the id straight to
|
|
1368
|
-
* `useWorkflowRun`.
|
|
1489
|
+
* Two things it does that a plain `useRunKey()` at the call site cannot, and
|
|
1490
|
+
* both are about a page that DID name one:
|
|
1369
1491
|
*
|
|
1370
|
-
*
|
|
1492
|
+
* - **It mints nothing when the caller has a key.** Minting writes to storage,
|
|
1493
|
+
* so an unconditional `useRunKey()` inside the hook would leave a slot behind
|
|
1494
|
+
* on every page that passes an account id and never reads it back.
|
|
1495
|
+
* - **It stays reactive to the caller's key.** A key that arrives late — an
|
|
1496
|
+
* account id resolved after a login — must reach the lookup, which re-asks on
|
|
1497
|
+
* a changed key by design; freezing it into `useState` would pin the page to
|
|
1498
|
+
* whatever it held on its first render.
|
|
1371
1499
|
*
|
|
1372
|
-
*
|
|
1373
|
-
*
|
|
1374
|
-
*
|
|
1375
|
-
* the right shape for a template teaching the primitives and the wrong shape to
|
|
1376
|
-
* write a third time — and it is easy to get subtly wrong: dropping the previous
|
|
1377
|
-
* run id before the new `POST` returns is what stops a finished result sitting
|
|
1378
|
-
* under a form that is already submitting again.
|
|
1500
|
+
* The minted half is still frozen for the component's life, which is what
|
|
1501
|
+
* `useRunKey` freezes it for: a fresh key per render would record every run
|
|
1502
|
+
* under a name the next load cannot produce.
|
|
1379
1503
|
*
|
|
1380
|
-
*
|
|
1381
|
-
*
|
|
1382
|
-
* watching (stream first, poll as its fallback, terminal stops) is entirely
|
|
1383
|
-
* `useWorkflowRun`'s, and `run` here IS its run.
|
|
1504
|
+
* @param explicit - The caller's own key, or undefined for a page with none.
|
|
1505
|
+
* @returns The key to record runs under and look them up by.
|
|
1384
1506
|
*
|
|
1385
|
-
*
|
|
1507
|
+
* @internal
|
|
1508
|
+
*/
|
|
1509
|
+
function useDefaultRunKey(explicit) {
|
|
1510
|
+
const [minted] = useState(() => explicit === void 0 ? mintRunKey("session") : "");
|
|
1511
|
+
return explicit ?? minted;
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* A lookup key for `useWorkflowSubmit({ key })`, stable across reloads.
|
|
1386
1515
|
*
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1389
|
-
*
|
|
1390
|
-
*
|
|
1391
|
-
*
|
|
1392
|
-
*
|
|
1393
|
-
* followed from the same id either way.
|
|
1516
|
+
* @param options - See the module doc for the whole argument. The storage kind
|
|
1517
|
+
* is read once, when the key is minted: a value that changed afterwards would
|
|
1518
|
+
* be asking to move a key that has already been recorded with a run.
|
|
1519
|
+
* @returns The key to record runs under and to look them up by — the same one
|
|
1520
|
+
* for the life of the component, and for the next load in the same tab (or the
|
|
1521
|
+
* same browser, under `"local"`).
|
|
1394
1522
|
*/
|
|
1523
|
+
function useRunKey(options = {}) {
|
|
1524
|
+
const { storage = "session" } = options;
|
|
1525
|
+
const [key] = useState(() => mintRunKey(storage));
|
|
1526
|
+
return key;
|
|
1527
|
+
}
|
|
1528
|
+
//#endregion
|
|
1529
|
+
//#region src/_recover-run.ts
|
|
1395
1530
|
/**
|
|
1396
|
-
*
|
|
1531
|
+
* Finding a run again when the page has lost its id.
|
|
1397
1532
|
*
|
|
1398
|
-
*
|
|
1399
|
-
*
|
|
1400
|
-
*
|
|
1533
|
+
* A run is durable and a page is not — which `useWorkflowRun`'s doc says, and
|
|
1534
|
+
* which was only half true of the hooks above it: the run id lived in plain
|
|
1535
|
+
* `useState`, so a refresh (or a same-tab navigation, or a crashed tab) left a
|
|
1536
|
+
* live run with nothing anywhere able to name it. The run really did continue;
|
|
1537
|
+
* the person really could not get back to it.
|
|
1401
1538
|
*
|
|
1402
|
-
*
|
|
1403
|
-
*
|
|
1404
|
-
*
|
|
1539
|
+
* `StartOptions.key` is the handle that survives that, and it always was — a
|
|
1540
|
+
* caller's own name for a run, indexed by the agent, read back with
|
|
1541
|
+
* `find(workflow, key)`. What was missing is the two lines that ASK. This is
|
|
1542
|
+
* them, plus the four decisions they turn out to carry.
|
|
1405
1543
|
*
|
|
1406
|
-
*
|
|
1407
|
-
* ```tsx
|
|
1408
|
-
* import { useWorkflows } from "@alexkroman1/aai-ui";
|
|
1544
|
+
* ## It is a MOUNT-time act, not "whenever there is no run"
|
|
1409
1545
|
*
|
|
1410
|
-
*
|
|
1411
|
-
*
|
|
1412
|
-
*
|
|
1413
|
-
*
|
|
1414
|
-
*
|
|
1415
|
-
*
|
|
1416
|
-
* if (error !== undefined) return <p role="alert">{error}</p>;
|
|
1417
|
-
* return (
|
|
1418
|
-
* <ul>
|
|
1419
|
-
* {workflows.map((summary) => (
|
|
1420
|
-
* <li key={summary.name}>
|
|
1421
|
-
* <button type="button" onClick={() => onPick(summary.name)}>
|
|
1422
|
-
* {summary.description ?? summary.name}
|
|
1423
|
-
* </button>
|
|
1424
|
-
* </li>
|
|
1425
|
-
* ))}
|
|
1426
|
-
* </ul>
|
|
1427
|
-
* );
|
|
1428
|
-
* }
|
|
1429
|
-
* ```
|
|
1546
|
+
* The tempting spelling is "if we hold no run id, look one up", and it breaks
|
|
1547
|
+
* `reset()`: a form put back to its initial state holds no run id, so the next
|
|
1548
|
+
* pass would re-adopt the very run the person had just dismissed — a Clear
|
|
1549
|
+
* button that clears nothing. So the lookup runs once per mount (and again only
|
|
1550
|
+
* if the KEY changes, which is a different person's run), and every later
|
|
1551
|
+
* absence of a run id is taken at face value.
|
|
1430
1552
|
*
|
|
1431
|
-
*
|
|
1432
|
-
* @returns The listing, its loading flag and its failure — see
|
|
1433
|
-
* {@link UseWorkflowsResult}.
|
|
1553
|
+
* ## The lookup NEVER wins a race against a submit
|
|
1434
1554
|
*
|
|
1435
|
-
*
|
|
1555
|
+
* A person who reloads and immediately submits has started the run they want,
|
|
1556
|
+
* and an answer that was already in flight names an older one. The caller
|
|
1557
|
+
* therefore adopts through `current ?? found`: the recovered id fills an empty
|
|
1558
|
+
* slot and never replaces a full one.
|
|
1559
|
+
*
|
|
1560
|
+
* ## A failed lookup is REPORTED
|
|
1561
|
+
*
|
|
1562
|
+
* The alternative is a page that quietly shows an empty form to somebody whose
|
|
1563
|
+
* run is live, who then starts a second one — the duplicated work the key
|
|
1564
|
+
* exists to prevent, and on a workflow app that is real money. A person who has
|
|
1565
|
+
* never run anything pays a banner they can ignore. Same trade as
|
|
1566
|
+
* `useWorkflows`, for the same reason: an empty answer here is a confident
|
|
1567
|
+
* false statement.
|
|
1568
|
+
*
|
|
1569
|
+
* ## It is ON by default, and it used to be opt-in
|
|
1570
|
+
*
|
|
1571
|
+
* The argument for opt-in was that a `key` on its own means only "record this
|
|
1572
|
+
* with the run" — which is what a voice agent's `ctx.workflows.start({ key })`
|
|
1573
|
+
* means, there being no page to put a run back on. A FORM is the other case: it
|
|
1574
|
+
* is the page, and losing the run is the thing it cannot recover from. Six of
|
|
1575
|
+
* six page templates wrote `useRunKey()` and `recover: true` together, which is
|
|
1576
|
+
* the same shape `session-resume-store.ts` names on the voice side — a default
|
|
1577
|
+
* in the wrong place — so `useWorkflowSubmit` now mints the key and asks.
|
|
1578
|
+
*
|
|
1579
|
+
* `enabled` remains, because `recover: false` remains: a page whose form must
|
|
1580
|
+
* always open empty says so, and then nothing here runs.
|
|
1436
1581
|
*/
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1582
|
+
/**
|
|
1583
|
+
* Look up the newest run for a key, once, as the component mounts.
|
|
1584
|
+
*
|
|
1585
|
+
* @param opts - See {@link RecoverRunOptions}.
|
|
1586
|
+
* @returns Whether the lookup is still out. A caller folds it into its own
|
|
1587
|
+
* `pending`, because a form offering Submit while a live run is arriving is a
|
|
1588
|
+
* form inviting a second one.
|
|
1589
|
+
*
|
|
1590
|
+
* @internal
|
|
1591
|
+
*/
|
|
1592
|
+
function useRecoveredRun(opts) {
|
|
1593
|
+
const { workflow, key, enabled, getClient } = opts;
|
|
1594
|
+
const [recovering, setRecovering] = useState(enabled);
|
|
1595
|
+
const handlers = useRef(opts);
|
|
1596
|
+
handlers.current = opts;
|
|
1445
1597
|
useEffect(() => {
|
|
1446
|
-
if (
|
|
1598
|
+
if (!enabled) return;
|
|
1447
1599
|
let cancelled = false;
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1600
|
+
setRecovering(true);
|
|
1601
|
+
getClient().find(workflow, key, { limit: 1 }).then((found) => {
|
|
1602
|
+
if (cancelled) return;
|
|
1603
|
+
const newest = found[0];
|
|
1604
|
+
if (newest !== void 0) handlers.current.onFound(newest.runId);
|
|
1605
|
+
setRecovering(false);
|
|
1454
1606
|
}).catch((err) => {
|
|
1455
1607
|
if (cancelled) return;
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
loading: false,
|
|
1459
|
-
error: errorMessage(err)
|
|
1460
|
-
});
|
|
1608
|
+
handlers.current.onError(errorMessage(err));
|
|
1609
|
+
setRecovering(false);
|
|
1461
1610
|
});
|
|
1462
1611
|
return () => {
|
|
1463
1612
|
cancelled = true;
|
|
1464
1613
|
};
|
|
1465
|
-
}, [
|
|
1466
|
-
|
|
1614
|
+
}, [
|
|
1615
|
+
enabled,
|
|
1616
|
+
key,
|
|
1617
|
+
workflow,
|
|
1618
|
+
getClient
|
|
1619
|
+
]);
|
|
1620
|
+
return recovering;
|
|
1467
1621
|
}
|
|
1622
|
+
//#endregion
|
|
1623
|
+
//#region src/_run-controls.ts
|
|
1468
1624
|
/**
|
|
1469
|
-
*
|
|
1625
|
+
* The two things a page does TO a run it started, bound to the run it has.
|
|
1470
1626
|
*
|
|
1471
|
-
*
|
|
1472
|
-
*
|
|
1473
|
-
*
|
|
1627
|
+
* `useWorkflowSubmit` and `useWorkflowStream` both hold a run id and neither
|
|
1628
|
+
* handed it back, so a page that wanted "send it now" or "stop" had to hold an
|
|
1629
|
+
* `api` of its own purely to write `api.wake(runId)` — which is the whole reason
|
|
1630
|
+
* the two raw-primitive template pages keep a client at module scope. That is a
|
|
1631
|
+
* page carrying the transport to make up for a hook withholding its own state.
|
|
1474
1632
|
*
|
|
1475
|
-
*
|
|
1476
|
-
*
|
|
1477
|
-
*
|
|
1478
|
-
*
|
|
1479
|
-
*
|
|
1480
|
-
|
|
1481
|
-
|
|
1633
|
+
* Both calls answer rather than fail when there is nothing to act on — `0`
|
|
1634
|
+
* sleeps ended, `false` this call did not end it — which is the SDK's own
|
|
1635
|
+
* contract for them (two tabs pressing Stop is ordinary), and it is what lets
|
|
1636
|
+
* the no-run case be the same answer rather than a special one a caller has to
|
|
1637
|
+
* branch on.
|
|
1638
|
+
*/
|
|
1639
|
+
/**
|
|
1640
|
+
* Bind `wake` and `cancel` to whatever run the hook is currently following.
|
|
1482
1641
|
*
|
|
1483
|
-
* @
|
|
1484
|
-
*
|
|
1485
|
-
*
|
|
1486
|
-
* import type { digest } from "./agent.ts";
|
|
1642
|
+
* @param runId - The live run, or `undefined` before one exists.
|
|
1643
|
+
* @param getClient - The stable getter from `useWorkflowApiRef`.
|
|
1644
|
+
* @returns Two callbacks, stable while `runId` is.
|
|
1487
1645
|
*
|
|
1488
|
-
*
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1646
|
+
* @internal
|
|
1647
|
+
*/
|
|
1648
|
+
function useRunControls(runId, getClient) {
|
|
1649
|
+
return {
|
|
1650
|
+
wake: useCallback(async () => {
|
|
1651
|
+
if (runId === void 0) return 0;
|
|
1652
|
+
return await getClient().wake(runId);
|
|
1653
|
+
}, [runId, getClient]),
|
|
1654
|
+
cancel: useCallback(async () => {
|
|
1655
|
+
if (runId === void 0) return false;
|
|
1656
|
+
return await getClient().cancel(runId);
|
|
1657
|
+
}, [runId, getClient])
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
//#endregion
|
|
1661
|
+
//#region src/_upload-pause.ts
|
|
1662
|
+
/**
|
|
1663
|
+
* The two buttons a page puts over a live upload, bound to whichever gate the
|
|
1664
|
+
* submission is currently holding.
|
|
1499
1665
|
*
|
|
1500
|
-
* @
|
|
1666
|
+
* `useWorkflowSubmit` and `useWorkflowStream` both own an {@link UploadGate} and
|
|
1667
|
+
* both reported the pause the same way: park the gate, then fold `paused` into
|
|
1668
|
+
* the status the bar is already drawing. Folded rather than replaced, because
|
|
1669
|
+
* everything else about that status — which file, how far, of how many — is
|
|
1670
|
+
* still true. Two copies of that rule are two copies that can stop agreeing
|
|
1671
|
+
* about what a paused bar says.
|
|
1501
1672
|
*/
|
|
1502
|
-
|
|
1503
|
-
|
|
1673
|
+
/**
|
|
1674
|
+
* Bind pause/resume to the live submission's gate.
|
|
1675
|
+
*
|
|
1676
|
+
* @param getGate - Reads the gate out of the caller's ref, so the callbacks stay
|
|
1677
|
+
* stable across submissions rather than being re-created per gate.
|
|
1678
|
+
* @param setUpload - The status setter the progress bar renders from.
|
|
1679
|
+
*
|
|
1680
|
+
* @internal
|
|
1681
|
+
*/
|
|
1682
|
+
function useUploadPause(getGate, setUpload) {
|
|
1683
|
+
const setPaused = useCallback((paused) => {
|
|
1684
|
+
setUpload((current) => current ? {
|
|
1685
|
+
...current,
|
|
1686
|
+
paused
|
|
1687
|
+
} : current);
|
|
1688
|
+
}, [setUpload]);
|
|
1689
|
+
return {
|
|
1690
|
+
pauseUpload: useCallback(() => {
|
|
1691
|
+
getGate()?.pause();
|
|
1692
|
+
setPaused(true);
|
|
1693
|
+
}, [getGate, setPaused]),
|
|
1694
|
+
resumeUpload: useCallback(() => {
|
|
1695
|
+
getGate()?.resume();
|
|
1696
|
+
setPaused(false);
|
|
1697
|
+
}, [getGate, setPaused])
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
//#endregion
|
|
1701
|
+
//#region src/_submission-state.ts
|
|
1702
|
+
/** The shared submission scaffold. See the module doc. */
|
|
1703
|
+
function useSubmissionState() {
|
|
1504
1704
|
const [runId, setRunId] = useState(void 0);
|
|
1505
1705
|
const [starting, setStarting] = useState(false);
|
|
1506
1706
|
const [startError, setStartError] = useState(void 0);
|
|
1507
1707
|
const [upload, setUpload] = useState(void 0);
|
|
1508
|
-
const
|
|
1509
|
-
const
|
|
1510
|
-
const tracked = useWorkflowRun(runId, omitUndefined({
|
|
1511
|
-
api,
|
|
1512
|
-
intervalMs
|
|
1513
|
-
}));
|
|
1514
|
-
const { wake, cancel } = useRunControls(runId, getClient);
|
|
1515
|
-
const recovering = useRecoveredRun({
|
|
1516
|
-
workflow,
|
|
1517
|
-
key,
|
|
1518
|
-
enabled: recover,
|
|
1519
|
-
getClient,
|
|
1520
|
-
onFound: (found) => {
|
|
1521
|
-
setRunId((current) => current ?? found);
|
|
1522
|
-
},
|
|
1523
|
-
onError: setStartError
|
|
1524
|
-
});
|
|
1525
|
-
const submit = useCallback(async (input) => {
|
|
1526
|
-
const client = getClient();
|
|
1708
|
+
const current = useRef(void 0);
|
|
1709
|
+
const begin = useCallback((token) => {
|
|
1527
1710
|
setStarting(true);
|
|
1528
1711
|
setStartError(void 0);
|
|
1529
1712
|
setRunId(void 0);
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
})).runId);
|
|
1540
|
-
} catch (err) {
|
|
1541
|
-
if (!current.gate.cancelled) setStartError(errorMessage(err));
|
|
1542
|
-
} finally {
|
|
1543
|
-
if (session.current === current) {
|
|
1544
|
-
session.current = void 0;
|
|
1545
|
-
setStarting(false);
|
|
1546
|
-
setUpload(void 0);
|
|
1547
|
-
}
|
|
1548
|
-
}
|
|
1549
|
-
}, [
|
|
1550
|
-
workflow,
|
|
1551
|
-
key,
|
|
1552
|
-
wait,
|
|
1553
|
-
parallel,
|
|
1554
|
-
getClient
|
|
1555
|
-
]);
|
|
1713
|
+
current.current?.gate.cancel();
|
|
1714
|
+
current.current = token;
|
|
1715
|
+
}, []);
|
|
1716
|
+
const end = useCallback((token) => {
|
|
1717
|
+
if (current.current !== token) return;
|
|
1718
|
+
current.current = void 0;
|
|
1719
|
+
setStarting(false);
|
|
1720
|
+
setUpload(void 0);
|
|
1721
|
+
}, []);
|
|
1556
1722
|
const reset = useCallback(() => {
|
|
1557
|
-
|
|
1558
|
-
|
|
1723
|
+
current.current?.gate.cancel();
|
|
1724
|
+
current.current = void 0;
|
|
1559
1725
|
setRunId(void 0);
|
|
1560
1726
|
setStartError(void 0);
|
|
1561
1727
|
setUpload(void 0);
|
|
1562
1728
|
}, []);
|
|
1563
|
-
const { pauseUpload, resumeUpload } = useUploadPause(useCallback(() =>
|
|
1729
|
+
const { pauseUpload, resumeUpload } = useUploadPause(useCallback(() => current.current?.gate, []), setUpload);
|
|
1564
1730
|
return {
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
wake,
|
|
1569
|
-
cancel,
|
|
1570
|
-
pauseUpload,
|
|
1571
|
-
resumeUpload,
|
|
1572
|
-
run: tracked.run,
|
|
1573
|
-
pending: recovering || starting || tracked.polling,
|
|
1731
|
+
runId,
|
|
1732
|
+
starting,
|
|
1733
|
+
startError,
|
|
1574
1734
|
upload,
|
|
1575
|
-
|
|
1735
|
+
actions: useMemo(() => ({
|
|
1736
|
+
setRunId,
|
|
1737
|
+
setStartError,
|
|
1738
|
+
setUpload,
|
|
1739
|
+
begin,
|
|
1740
|
+
end,
|
|
1741
|
+
reset,
|
|
1742
|
+
pauseUpload,
|
|
1743
|
+
resumeUpload
|
|
1744
|
+
}), [
|
|
1745
|
+
begin,
|
|
1746
|
+
end,
|
|
1747
|
+
reset,
|
|
1748
|
+
pauseUpload,
|
|
1749
|
+
resumeUpload
|
|
1750
|
+
])
|
|
1576
1751
|
};
|
|
1577
1752
|
}
|
|
1578
1753
|
//#endregion
|
|
1579
|
-
//#region
|
|
1580
|
-
/** @jsxImportSource react */
|
|
1754
|
+
//#region src/_upload-recall.ts
|
|
1581
1755
|
/**
|
|
1582
|
-
*
|
|
1756
|
+
* Where an upload's ID survives a page RELOAD.
|
|
1583
1757
|
*
|
|
1584
|
-
*
|
|
1585
|
-
*
|
|
1586
|
-
*
|
|
1587
|
-
*
|
|
1588
|
-
*
|
|
1758
|
+
* A streamed upload is resumable because its id outlives the attempt that began
|
|
1759
|
+
* it — `_upload-files.ts` says so, and `_upload-session.ts` turns that into a
|
|
1760
|
+
* pause a person can press. Both of them hold the id in MEMORY: the walk's
|
|
1761
|
+
* `UploadSession` lives in a `useRef`, so a reload was the one interruption the
|
|
1762
|
+
* mechanism could not survive. Everything else was already in place — the windows
|
|
1763
|
+
* were still in the store, the agent could still name them
|
|
1764
|
+
* (`UploadInfo.ranges`), and the id was minted in the browser — and the browser
|
|
1765
|
+
* had thrown away the only name for them. So a person who refreshed at 90% of a
|
|
1766
|
+
* 200 MB recording sent the whole file again, which is the one interruption they
|
|
1767
|
+
* are most likely to cause on purpose.
|
|
1589
1768
|
*
|
|
1590
|
-
*
|
|
1769
|
+
* This is that name, written down. It is what tus-js-client's `urlStorage` and
|
|
1770
|
+
* Uppy's Golden Retriever sell, in the shape `session-resume-store.ts` already
|
|
1771
|
+
* uses for a session id.
|
|
1591
1772
|
*
|
|
1592
|
-
* A
|
|
1593
|
-
* nested object or an array does not — every choice (a JSON textarea, a repeater,
|
|
1594
|
-
* a comma-separated string) is a guess about what the author meant, and a guess
|
|
1595
|
-
* that produces a value the schema then rejects is worse than no field at all.
|
|
1596
|
-
* So those are SKIPPED, and the fields for them are written by hand: every field
|
|
1597
|
-
* in this package is a plain named control, so a hand-written one composes with
|
|
1598
|
-
* a generated one inside the same {@link Form}.
|
|
1599
|
-
*/
|
|
1600
|
-
/**
|
|
1601
|
-
* Render one field per scalar property of a workflow's input schema.
|
|
1773
|
+
* ## A FINGERPRINT, because a `File` has no name a page can address
|
|
1602
1774
|
*
|
|
1603
|
-
*
|
|
1604
|
-
*
|
|
1605
|
-
*
|
|
1606
|
-
*
|
|
1607
|
-
*
|
|
1608
|
-
*
|
|
1775
|
+
* A file from a picker carries no path and no handle, so the key is what
|
|
1776
|
+
* tus-js-client fingerprints on: size, last-modified, type and name. Two
|
|
1777
|
+
* different files agreeing on all four is the case this cannot tell apart — and
|
|
1778
|
+
* the reason NOTHING here decides to resume. `_upload-files.ts` asks the agent
|
|
1779
|
+
* what the id actually holds before sending a byte to it, so a wrong hit costs
|
|
1780
|
+
* one `GET` and a fresh id rather than a corrupted upload.
|
|
1609
1781
|
*
|
|
1610
|
-
*
|
|
1611
|
-
* declared input takes anything, and a form for "anything" is not a form — and
|
|
1612
|
-
* nothing while a named lookup is still in flight, so the hand-written fields
|
|
1613
|
-
* beside it are not reordered when the schema lands.
|
|
1782
|
+
* ## `sessionStorage`, deliberately
|
|
1614
1783
|
*
|
|
1615
|
-
*
|
|
1616
|
-
*
|
|
1617
|
-
*
|
|
1618
|
-
*
|
|
1619
|
-
*
|
|
1784
|
+
* The same call `session-resume-store.ts` makes, for a reason that happens to be
|
|
1785
|
+
* stronger here: a reload and a same-tab navigation are exactly what this is for,
|
|
1786
|
+
* and an id from yesterday names an upload the agent's sweep has very likely
|
|
1787
|
+
* already collected. A tab is also the boundary the walk itself has — two tabs
|
|
1788
|
+
* uploading the same recording are two submissions.
|
|
1620
1789
|
*
|
|
1621
|
-
*
|
|
1622
|
-
*
|
|
1623
|
-
*
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
*
|
|
1628
|
-
* );
|
|
1629
|
-
* }
|
|
1630
|
-
* ```
|
|
1790
|
+
* Every access is guarded. Storage throws outright in Safari private mode and
|
|
1791
|
+
* under a blocking policy, and an upload that cannot be REMEMBERED must degrade
|
|
1792
|
+
* to the upload we would have done anyway rather than failing to start.
|
|
1793
|
+
*/
|
|
1794
|
+
const PREFIX = "aai:upload:";
|
|
1795
|
+
/**
|
|
1796
|
+
* How many ids one form keeps.
|
|
1631
1797
|
*
|
|
1632
|
-
*
|
|
1798
|
+
* A cap rather than an expiry, because `sessionStorage` already expires with the
|
|
1799
|
+
* tab and an entry is ~80 bytes. What it bounds is the long-lived tab that
|
|
1800
|
+
* submits a hundred files: the oldest go first, and the id most likely to be
|
|
1801
|
+
* worth resuming is the one written last.
|
|
1802
|
+
*/
|
|
1803
|
+
const MAX_REMEMBERED = 32;
|
|
1804
|
+
/** One form's slot in storage. */
|
|
1805
|
+
function keyFor(scope) {
|
|
1806
|
+
return `${PREFIX}${scope}`;
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* What names this file across a reload.
|
|
1633
1810
|
*
|
|
1634
|
-
*
|
|
1811
|
+
* The four fields a browser gives a picked file that do not change between loads.
|
|
1812
|
+
* `name` last because it is the one a person can read in a debugger.
|
|
1635
1813
|
*/
|
|
1636
|
-
function
|
|
1637
|
-
|
|
1638
|
-
const summary = typeof workflow === "string" ? workflows.find((entry) => entry.name === workflow) : workflow;
|
|
1639
|
-
useDeclareFieldsPending(loading);
|
|
1640
|
-
const schema = asObjectSchema(summary?.inputSchema);
|
|
1641
|
-
if (!schema?.properties) return null;
|
|
1642
|
-
const required = new Set(schema.required ?? []);
|
|
1643
|
-
const uploads = new Set(summary?.uploads ?? []);
|
|
1644
|
-
return /* @__PURE__ */ jsx(Fragment, { children: Object.entries(schema.properties).map(([name, property]) => /* @__PURE__ */ jsx(SchemaField, {
|
|
1645
|
-
name,
|
|
1646
|
-
property,
|
|
1647
|
-
required: required.has(name),
|
|
1648
|
-
upload: uploads.has(name)
|
|
1649
|
-
}, name)) });
|
|
1814
|
+
function fingerprint(file) {
|
|
1815
|
+
return `${file.size}:${file.lastModified}:${file.type}:${file.name}`;
|
|
1650
1816
|
}
|
|
1651
|
-
/**
|
|
1652
|
-
function
|
|
1653
|
-
const
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
upload: true,
|
|
1661
|
-
...hint
|
|
1662
|
-
});
|
|
1663
|
-
if (Array.isArray(property.enum) && property.enum.length > 0) return /* @__PURE__ */ jsx(SelectField, {
|
|
1664
|
-
name,
|
|
1665
|
-
label,
|
|
1666
|
-
required,
|
|
1667
|
-
options: property.enum.map((value) => String(value)),
|
|
1668
|
-
...hint,
|
|
1669
|
-
...defaults
|
|
1670
|
-
});
|
|
1671
|
-
const type = typeOf(property);
|
|
1672
|
-
switch (type) {
|
|
1673
|
-
case "boolean": return /* @__PURE__ */ jsx(CheckboxField, {
|
|
1674
|
-
name,
|
|
1675
|
-
label,
|
|
1676
|
-
defaultChecked: property.default === true,
|
|
1677
|
-
...hint
|
|
1678
|
-
});
|
|
1679
|
-
case "number":
|
|
1680
|
-
case "integer": return /* @__PURE__ */ jsx(NumberField, {
|
|
1681
|
-
name,
|
|
1682
|
-
label,
|
|
1683
|
-
required,
|
|
1684
|
-
step: type === "integer" ? 1 : "any",
|
|
1685
|
-
...hint,
|
|
1686
|
-
...defaults
|
|
1687
|
-
});
|
|
1688
|
-
case "string": return /* @__PURE__ */ jsx(TextField, {
|
|
1689
|
-
name,
|
|
1690
|
-
label,
|
|
1691
|
-
required,
|
|
1692
|
-
...hint,
|
|
1693
|
-
...defaults
|
|
1694
|
-
});
|
|
1695
|
-
default: return null;
|
|
1817
|
+
/** This scope's remembered ids, or nothing at all — a parse failure is nothing. */
|
|
1818
|
+
function read(scope) {
|
|
1819
|
+
const raw = storageGet("session", keyFor(scope));
|
|
1820
|
+
if (raw === void 0) return {};
|
|
1821
|
+
try {
|
|
1822
|
+
const parsed = JSON.parse(raw);
|
|
1823
|
+
return isRecord(parsed) ? parsed : {};
|
|
1824
|
+
} catch {
|
|
1825
|
+
return {};
|
|
1696
1826
|
}
|
|
1697
1827
|
}
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
const { type } = property;
|
|
1701
|
-
if (typeof type === "string") return type;
|
|
1702
|
-
return Array.isArray(type) ? type.find((member) => member !== "null") : void 0;
|
|
1703
|
-
}
|
|
1704
|
-
/** The listing's `unknown` schema as the object shape this reads, when it is one. */
|
|
1705
|
-
function asObjectSchema(schema) {
|
|
1706
|
-
return isRecord(schema) ? schema : void 0;
|
|
1828
|
+
function write(scope, entries) {
|
|
1829
|
+
storageSet("session", keyFor(scope), JSON.stringify(entries));
|
|
1707
1830
|
}
|
|
1708
1831
|
/**
|
|
1709
|
-
*
|
|
1832
|
+
* The id this file was last being stored under in this tab, if any.
|
|
1710
1833
|
*
|
|
1711
|
-
* A
|
|
1712
|
-
*
|
|
1834
|
+
* A hit is a CANDIDATE and never a decision — see the module doc.
|
|
1835
|
+
*
|
|
1836
|
+
* @internal
|
|
1713
1837
|
*/
|
|
1714
|
-
function
|
|
1715
|
-
const
|
|
1716
|
-
return
|
|
1838
|
+
function recallUploadId(scope, file) {
|
|
1839
|
+
const found = read(scope)[fingerprint(file)];
|
|
1840
|
+
return typeof found === "string" ? found : void 0;
|
|
1717
1841
|
}
|
|
1718
|
-
//#endregion
|
|
1719
|
-
//#region components/workflow-progress.tsx
|
|
1720
|
-
/** @jsxImportSource react */
|
|
1721
1842
|
/**
|
|
1722
|
-
*
|
|
1843
|
+
* Remember the id this file is being stored under.
|
|
1723
1844
|
*
|
|
1724
|
-
*
|
|
1725
|
-
*
|
|
1726
|
-
*
|
|
1727
|
-
* in a `"use step"` body), which is the only channel a workflow has before it
|
|
1728
|
-
* produces an output.
|
|
1845
|
+
* Called before the first byte leaves rather than after the last one lands: the
|
|
1846
|
+
* reload this exists for happens in between, and an id written at the end is an
|
|
1847
|
+
* id written for the one case that did not need it.
|
|
1729
1848
|
*
|
|
1730
|
-
*
|
|
1731
|
-
|
|
1732
|
-
|
|
1849
|
+
* @internal
|
|
1850
|
+
*/
|
|
1851
|
+
function rememberUploadId(scope, file, id) {
|
|
1852
|
+
const entries = read(scope);
|
|
1853
|
+
const key = fingerprint(file);
|
|
1854
|
+
delete entries[key];
|
|
1855
|
+
entries[key] = id;
|
|
1856
|
+
const keys = Object.keys(entries);
|
|
1857
|
+
for (const stale of keys.slice(0, Math.max(0, keys.length - MAX_REMEMBERED))) delete entries[stale];
|
|
1858
|
+
write(scope, entries);
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Forget it: the agent holds nothing resumable under this id.
|
|
1733
1862
|
*
|
|
1734
|
-
*
|
|
1735
|
-
*
|
|
1736
|
-
*
|
|
1737
|
-
* indistinguishable from the chunk list alone.
|
|
1738
|
-
* - **The lines are TEXT, not elements.** They are append-only and two rounds
|
|
1739
|
-
* legitimately produce identical text, so there is no stable per-line key to
|
|
1740
|
-
* give React. Joining sidesteps the question instead of suppressing the lint
|
|
1741
|
-
* rule that asks it.
|
|
1742
|
-
* - **They REPLAY.** Chunks are retained with the run, so a reload mid-run —
|
|
1743
|
-
* or opening a finished run tomorrow — shows how it got there rather than an
|
|
1744
|
-
* empty box. That is `useWorkflowProgress`'s doing; this is what makes it
|
|
1745
|
-
* visible.
|
|
1863
|
+
* The other half of the agent deciding. Without it a swept upload is re-read on
|
|
1864
|
+
* every submission of the same file for the life of the tab, which is a round
|
|
1865
|
+
* trip spent learning the same 404.
|
|
1746
1866
|
*
|
|
1747
|
-
* @
|
|
1748
|
-
|
|
1749
|
-
|
|
1867
|
+
* @internal
|
|
1868
|
+
*/
|
|
1869
|
+
function forgetUploadId(scope, file) {
|
|
1870
|
+
const entries = read(scope);
|
|
1871
|
+
const key = fingerprint(file);
|
|
1872
|
+
if (!(key in entries)) return;
|
|
1873
|
+
delete entries[key];
|
|
1874
|
+
write(scope, entries);
|
|
1875
|
+
}
|
|
1876
|
+
//#endregion
|
|
1877
|
+
//#region src/_upload-session.ts
|
|
1878
|
+
/** Whether this rejection is an abort, in either of the two shapes runtimes throw. */
|
|
1879
|
+
function isAbortError(err) {
|
|
1880
|
+
return err instanceof Error && err.name === "AbortError";
|
|
1881
|
+
}
|
|
1882
|
+
/** A fresh upload id: a capability, so it is random rather than derived. */
|
|
1883
|
+
function randomUploadId() {
|
|
1884
|
+
return crypto.randomUUID().replaceAll("-", "");
|
|
1885
|
+
}
|
|
1886
|
+
/**
|
|
1887
|
+
* Send one file, waiting out however many pauses the person takes.
|
|
1750
1888
|
*
|
|
1751
|
-
*
|
|
1752
|
-
*
|
|
1753
|
-
*
|
|
1754
|
-
*
|
|
1889
|
+
* The loop from the module doc, written once: both hooks need exactly this and a
|
|
1890
|
+
* second copy of it is a second place for the abort/pause distinction to be got
|
|
1891
|
+
* wrong. `send` is handed whether this attempt must CLAIM the id as its own —
|
|
1892
|
+
* false the first time, since a fresh id has nothing to resume and saying
|
|
1893
|
+
* otherwise waives the refusal that makes a caller-chosen id safe.
|
|
1755
1894
|
*
|
|
1756
|
-
*
|
|
1895
|
+
* Throws whatever `send` threw, except an abort the gate caused. A cancelled gate
|
|
1896
|
+
* throws too: the caller distinguishes it by reading `gate.cancelled`, which is
|
|
1897
|
+
* how an abandoned submission unwinds without being reported as a failure.
|
|
1898
|
+
*/
|
|
1899
|
+
async function sendThroughGate(gate, send) {
|
|
1900
|
+
let tried = false;
|
|
1901
|
+
for (;;) {
|
|
1902
|
+
await gate.settle();
|
|
1903
|
+
if (gate.cancelled) throw new Error("Upload cancelled.");
|
|
1904
|
+
const resume = tried;
|
|
1905
|
+
tried = true;
|
|
1906
|
+
try {
|
|
1907
|
+
await send(resume);
|
|
1908
|
+
return;
|
|
1909
|
+
} catch (err) {
|
|
1910
|
+
if (gate.cancelled || !isAbortError(err)) throw err;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
/**
|
|
1915
|
+
* A gate, open.
|
|
1757
1916
|
*
|
|
1758
|
-
*
|
|
1917
|
+
* One per upload rather than one per hook: the id and the windows already stored
|
|
1918
|
+
* belong to a file, so a gate that outlived its file would resume something else.
|
|
1759
1919
|
*/
|
|
1760
|
-
function
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1920
|
+
function createUploadGate() {
|
|
1921
|
+
let controller = new AbortController();
|
|
1922
|
+
let paused = false;
|
|
1923
|
+
let cancelled = false;
|
|
1924
|
+
let open;
|
|
1925
|
+
let closed;
|
|
1926
|
+
return {
|
|
1927
|
+
get paused() {
|
|
1928
|
+
return paused;
|
|
1929
|
+
},
|
|
1930
|
+
get cancelled() {
|
|
1931
|
+
return cancelled;
|
|
1932
|
+
},
|
|
1933
|
+
get signal() {
|
|
1934
|
+
return controller.signal;
|
|
1935
|
+
},
|
|
1936
|
+
pause() {
|
|
1937
|
+
if (paused || cancelled) return;
|
|
1938
|
+
paused = true;
|
|
1939
|
+
const gate = Promise.withResolvers();
|
|
1940
|
+
closed = gate.promise;
|
|
1941
|
+
open = gate.resolve;
|
|
1942
|
+
controller.abort();
|
|
1943
|
+
},
|
|
1944
|
+
resume() {
|
|
1945
|
+
if (!paused || cancelled) return;
|
|
1946
|
+
paused = false;
|
|
1947
|
+
controller = new AbortController();
|
|
1948
|
+
open?.();
|
|
1949
|
+
open = void 0;
|
|
1950
|
+
closed = void 0;
|
|
1951
|
+
},
|
|
1952
|
+
cancel() {
|
|
1953
|
+
if (cancelled) return;
|
|
1954
|
+
cancelled = true;
|
|
1955
|
+
paused = false;
|
|
1956
|
+
controller.abort();
|
|
1957
|
+
open?.();
|
|
1958
|
+
open = void 0;
|
|
1959
|
+
closed = void 0;
|
|
1960
|
+
},
|
|
1961
|
+
async settle() {
|
|
1962
|
+
if (closed) await closed;
|
|
1963
|
+
}
|
|
1964
|
+
};
|
|
1768
1965
|
}
|
|
1769
1966
|
//#endregion
|
|
1770
|
-
//#region
|
|
1771
|
-
/** @jsxImportSource react */
|
|
1967
|
+
//#region src/_workflow-files.ts
|
|
1772
1968
|
/**
|
|
1773
|
-
*
|
|
1969
|
+
* Which of a submitted form's values are FILES.
|
|
1774
1970
|
*
|
|
1775
|
-
*
|
|
1776
|
-
*
|
|
1777
|
-
*
|
|
1778
|
-
*
|
|
1779
|
-
*
|
|
1780
|
-
*
|
|
1781
|
-
|
|
1782
|
-
|
|
1971
|
+
* Its own module because both submit hooks need the identical answer and then do
|
|
1972
|
+
* two different things with it — `useWorkflowSubmit` stores each file and passes
|
|
1973
|
+
* its id, `useWorkflowStream` mints the id first so the run can start on it.
|
|
1974
|
+
* A second copy of this predicate would be a form field that one hook treats as a
|
|
1975
|
+
* file and the other does not, which is invisible until the run reads the wrong
|
|
1976
|
+
* kind of string.
|
|
1977
|
+
*/
|
|
1978
|
+
/**
|
|
1979
|
+
* The files a submitted field carries, if that is what it carries.
|
|
1783
1980
|
*
|
|
1784
|
-
*
|
|
1785
|
-
*
|
|
1786
|
-
*
|
|
1787
|
-
* `createWorkflowApi()` / `useWorkflowRun()`.
|
|
1981
|
+
* An array counts only when it is files ALL the way through — a mixed array is
|
|
1982
|
+
* some other field's value that happens to contain one, and turning half of it
|
|
1983
|
+
* into ids would corrupt it silently.
|
|
1788
1984
|
*/
|
|
1985
|
+
function filesOf(value) {
|
|
1986
|
+
if (value instanceof File) return [value];
|
|
1987
|
+
if (!Array.isArray(value)) return [];
|
|
1988
|
+
const files = value.filter((one) => one instanceof File);
|
|
1989
|
+
return files.length > 0 && files.length === value.length ? files : [];
|
|
1990
|
+
}
|
|
1789
1991
|
/**
|
|
1790
|
-
*
|
|
1992
|
+
* The input properties still carrying a `File` — i.e. the ones that CANNOT survive
|
|
1993
|
+
* being sent.
|
|
1791
1994
|
*
|
|
1792
|
-
*
|
|
1793
|
-
*
|
|
1794
|
-
*
|
|
1995
|
+
* A run input is JSON, and `JSON.stringify(new File(…))` is `{}` — no `toJSON`, no
|
|
1996
|
+
* own enumerable properties. So a File left in a payload does not fail to send: it
|
|
1997
|
+
* arrives as an empty object, and the workflow rejects it against whatever its own
|
|
1998
|
+
* schema says the property should be. Measured in production as
|
|
1999
|
+
* `Invalid input for workflow "transcribe": recording: Invalid input` — a message
|
|
2000
|
+
* about a type, on a form where the user had picked a perfectly good file.
|
|
1795
2001
|
*
|
|
1796
|
-
* @
|
|
1797
|
-
*
|
|
1798
|
-
*
|
|
1799
|
-
|
|
2002
|
+
* Exported beside {@link filesOf} because it is the same question asked at the
|
|
2003
|
+
* other end: that one decides which fields to UPLOAD, this one checks that none
|
|
2004
|
+
* were missed. Both hooks are the callers.
|
|
2005
|
+
*/
|
|
2006
|
+
function fileFields(input) {
|
|
2007
|
+
if (!isRecord(input)) return [];
|
|
2008
|
+
return Object.entries(input).filter(([, value]) => filesOf(value).length > 0).map(([key]) => key);
|
|
2009
|
+
}
|
|
2010
|
+
//#endregion
|
|
2011
|
+
//#region src/_upload-files.ts
|
|
2012
|
+
/**
|
|
2013
|
+
* Turning a form's `File`s into stored upload ids, pauses and all.
|
|
1800
2014
|
*
|
|
1801
|
-
*
|
|
1802
|
-
*
|
|
2015
|
+
* Split out of `use-workflow-form.ts` for the 500-line cap, and the seam is a
|
|
2016
|
+
* real one: that module is the two HOOKS and the state between them, where this
|
|
2017
|
+
* is the walk over a submitted input — which is the only part of it that knows
|
|
2018
|
+
* what a `File` is, holds a loop, and survives being re-entered.
|
|
1803
2019
|
*
|
|
1804
|
-
*
|
|
1805
|
-
*
|
|
1806
|
-
*
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
2020
|
+
* `_`-internal. `useWorkflowSubmit` is the only caller; `useWorkflowStream` sends
|
|
2021
|
+
* one file rather than walking an input and shares only the gate underneath both
|
|
2022
|
+
* (`_upload-session.ts`).
|
|
2023
|
+
*/
|
|
2024
|
+
/** A fresh session for one submission of `workflow`. */
|
|
2025
|
+
function createUploadSession(workflow) {
|
|
2026
|
+
return {
|
|
2027
|
+
scope: workflow,
|
|
2028
|
+
ids: /* @__PURE__ */ new Map(),
|
|
2029
|
+
stored: /* @__PURE__ */ new Map(),
|
|
2030
|
+
tried: /* @__PURE__ */ new Set(),
|
|
2031
|
+
gate: createUploadGate()
|
|
2032
|
+
};
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* The id to store this file under: the one a previous page load was using, or a
|
|
2036
|
+
* fresh one.
|
|
1816
2037
|
*
|
|
1817
|
-
*
|
|
1818
|
-
*
|
|
2038
|
+
* **The AGENT decides, never the fingerprint.** Two files can agree on every
|
|
2039
|
+
* field `_upload-recall.ts` keys by, so the recalled id is a candidate that has to
|
|
2040
|
+
* be checked before a byte is sent to it — and the check is cheap and exact,
|
|
2041
|
+
* because `uploadInfo` is the same record a resume already reads.
|
|
1819
2042
|
*
|
|
1820
|
-
*
|
|
2043
|
+
* Three answers, and the third is why this is not just a storage lookup:
|
|
1821
2044
|
*
|
|
1822
|
-
*
|
|
2045
|
+
* - **Complete.** Every byte is in from a load that is gone, so there is nothing
|
|
2046
|
+
* to send: the caller takes the id and starts the run. This is the refresh that
|
|
2047
|
+
* costs one `GET` instead of a second 200 MB upload.
|
|
2048
|
+
* - **Unfinished, with windows.** `UploadInfo.ranges` is what makes an
|
|
2049
|
+
* upload resumable at all, so the id is reused and the attempt claims it.
|
|
2050
|
+
* - **Anything else.** A 404 (swept, or never seen), a failure, or an unfinished
|
|
2051
|
+
* upload reporting NO windows — which is a partial single `PUT`, and a second
|
|
2052
|
+
* `PUT` to that id is a 409 rather than an append (`streamUploadFile`). Reusing
|
|
2053
|
+
* it would turn a reload into a failure the person cannot clear, so the entry is
|
|
2054
|
+
* dropped and the file gets a fresh id.
|
|
1823
2055
|
*/
|
|
1824
|
-
function
|
|
1825
|
-
const
|
|
1826
|
-
|
|
1827
|
-
|
|
2056
|
+
async function claimId(api, session, file) {
|
|
2057
|
+
const remembered = recallUploadId(session.scope, file);
|
|
2058
|
+
if (remembered === void 0) return {
|
|
2059
|
+
id: randomUploadId(),
|
|
2060
|
+
complete: false
|
|
2061
|
+
};
|
|
2062
|
+
const info = await api.uploadInfo(remembered).catch(() => void 0);
|
|
2063
|
+
if (info?.complete === true) return {
|
|
2064
|
+
id: remembered,
|
|
2065
|
+
complete: true
|
|
2066
|
+
};
|
|
2067
|
+
if (info !== void 0 && (info.ranges?.length ?? 0) > 0) {
|
|
2068
|
+
session.tried.add(file);
|
|
2069
|
+
return {
|
|
2070
|
+
id: remembered,
|
|
2071
|
+
complete: false
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
forgetUploadId(session.scope, file);
|
|
2075
|
+
return {
|
|
2076
|
+
id: randomUploadId(),
|
|
2077
|
+
complete: false
|
|
2078
|
+
};
|
|
1828
2079
|
}
|
|
1829
|
-
//#endregion
|
|
1830
|
-
//#region use-download-url.ts
|
|
1831
2080
|
/**
|
|
1832
|
-
* `
|
|
1833
|
-
*
|
|
2081
|
+
* Replace every `File` in a submitted form with the id of a stored upload,
|
|
2082
|
+
* reporting how far each one has got.
|
|
1834
2083
|
*
|
|
1835
|
-
*
|
|
1836
|
-
*
|
|
1837
|
-
*
|
|
1838
|
-
*
|
|
1839
|
-
* two that are really the point are the two the four lines are wrapped in:
|
|
2084
|
+
* Sequential rather than `Promise.all`: these are large bodies, and a form with
|
|
2085
|
+
* two 200 MB recordings should send them one after another rather than compete
|
|
2086
|
+
* for the same connection. That is also what makes a single bar honest — one
|
|
2087
|
+
* file is in flight at a time, and `index`/`count` say which.
|
|
1840
2088
|
*
|
|
1841
|
-
*
|
|
1842
|
-
*
|
|
1843
|
-
*
|
|
1844
|
-
* in the size of the files.
|
|
1845
|
-
* - **A `cancelled` flag.** A second run settling while the first download is
|
|
1846
|
-
* still in flight otherwise sets state from the stale one, and the page plays
|
|
1847
|
-
* the previous run's audio under the current run's transcript — a wrong answer
|
|
1848
|
-
* that looks like a right one.
|
|
2089
|
+
* Anything that is not a `File` (or an array of them) passes through untouched,
|
|
2090
|
+
* so this is invisible to every form that has none — including one whose values
|
|
2091
|
+
* are not an object at all, which `submit` accepts.
|
|
1849
2092
|
*
|
|
1850
|
-
*
|
|
1851
|
-
*
|
|
1852
|
-
*
|
|
1853
|
-
*
|
|
2093
|
+
* ## `uploadStream`, not `upload`, and the id is the reason
|
|
2094
|
+
*
|
|
2095
|
+
* The difference between the two calls is only who mints the id — and that is
|
|
2096
|
+
* exactly what decides whether an interrupted upload can be picked up again. An
|
|
2097
|
+
* `upload` mints its own at the END and hands it back, so a caller whose upload
|
|
2098
|
+
* died has nothing to name what was stored and no choice but to send the file
|
|
2099
|
+
* again. A `uploadStream` is told the id up front, so the windows already in the
|
|
2100
|
+
* store are addressable, which is what both a pause and a server restart need.
|
|
2101
|
+
*
|
|
2102
|
+
* Nothing else about the submission changes: the run is still started after the
|
|
2103
|
+
* last byte lands, so the incomplete record a streamed upload leaves along the
|
|
2104
|
+
* way is one nobody reads.
|
|
1854
2105
|
*/
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
const
|
|
2106
|
+
async function uploadFiles(api, input, report, parallel, session) {
|
|
2107
|
+
if (!isRecord(input)) return input;
|
|
2108
|
+
const entries = Object.entries(input);
|
|
2109
|
+
const count = entries.reduce((total, [, value]) => total + filesOf(value).length, 0);
|
|
2110
|
+
let index = 0;
|
|
2111
|
+
const store = async (file) => {
|
|
2112
|
+
index += 1;
|
|
2113
|
+
const done = session.stored.get(file);
|
|
2114
|
+
if (done !== void 0) return done;
|
|
2115
|
+
const position = {
|
|
2116
|
+
name: file.name,
|
|
2117
|
+
index,
|
|
2118
|
+
count
|
|
2119
|
+
};
|
|
2120
|
+
const known = session.ids.get(file);
|
|
2121
|
+
const claimed = known === void 0 ? await claimId(api, session, file) : {
|
|
2122
|
+
id: known,
|
|
2123
|
+
complete: false
|
|
2124
|
+
};
|
|
2125
|
+
const id = claimed.id;
|
|
2126
|
+
if (known === void 0) {
|
|
2127
|
+
session.ids.set(file, id);
|
|
2128
|
+
rememberUploadId(session.scope, file, id);
|
|
2129
|
+
}
|
|
2130
|
+
if (claimed.complete) {
|
|
2131
|
+
report({
|
|
2132
|
+
...position,
|
|
2133
|
+
loaded: file.size,
|
|
2134
|
+
total: file.size,
|
|
2135
|
+
fraction: 1,
|
|
2136
|
+
paused: false
|
|
2137
|
+
});
|
|
2138
|
+
session.stored.set(file, id);
|
|
2139
|
+
return id;
|
|
2140
|
+
}
|
|
2141
|
+
await sendThroughGate(session.gate, async (resume) => {
|
|
2142
|
+
await api.uploadStream(id, file, {
|
|
2143
|
+
name: file.name,
|
|
2144
|
+
signal: session.gate.signal,
|
|
2145
|
+
onProgress: (progress) => report({
|
|
2146
|
+
...position,
|
|
2147
|
+
...progress,
|
|
2148
|
+
paused: session.gate.paused
|
|
2149
|
+
}),
|
|
2150
|
+
...omitUndefined({
|
|
2151
|
+
parallel,
|
|
2152
|
+
resume: resume || session.tried.has(file) ? true : void 0
|
|
2153
|
+
})
|
|
2154
|
+
});
|
|
2155
|
+
});
|
|
2156
|
+
session.stored.set(file, id);
|
|
2157
|
+
return id;
|
|
2158
|
+
};
|
|
2159
|
+
const out = {};
|
|
2160
|
+
for (const [name, value] of entries) {
|
|
2161
|
+
if (value instanceof File) {
|
|
2162
|
+
out[name] = await store(value);
|
|
2163
|
+
continue;
|
|
2164
|
+
}
|
|
2165
|
+
const chosen = filesOf(value);
|
|
2166
|
+
if (chosen.length === 0) {
|
|
2167
|
+
out[name] = value;
|
|
2168
|
+
continue;
|
|
2169
|
+
}
|
|
2170
|
+
const ids = [];
|
|
2171
|
+
for (const file of chosen) ids.push(await store(file));
|
|
2172
|
+
out[name] = ids;
|
|
2173
|
+
}
|
|
2174
|
+
return out;
|
|
2175
|
+
}
|
|
2176
|
+
//#endregion
|
|
2177
|
+
//#region src/_upload-report.ts
|
|
1859
2178
|
/**
|
|
1860
|
-
*
|
|
1861
|
-
*
|
|
1862
|
-
* @param uploadId - The id a completed run reported, or `undefined` before one
|
|
1863
|
-
* exists — which is what a page passes straight through while it waits, and
|
|
1864
|
-
* reports as idle rather than pending.
|
|
1865
|
-
* @param opts - See {@link UseDownloadUrlOptions}.
|
|
1866
|
-
* @returns See {@link UseDownloadUrlResult}.
|
|
1867
|
-
*
|
|
1868
|
-
* @example
|
|
1869
|
-
* ```tsx no-check
|
|
1870
|
-
* import { useDownloadUrl, useWorkflowSubmit } from "@alexkroman1/aai-ui";
|
|
1871
|
-
* import type { spokenSummary } from "./agent.ts";
|
|
2179
|
+
* What the bar draws, as a comparable string.
|
|
1872
2180
|
*
|
|
1873
|
-
*
|
|
1874
|
-
*
|
|
1875
|
-
*
|
|
1876
|
-
*
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
*
|
|
1885
|
-
* ```
|
|
2181
|
+
* The fraction is quantized to a tenth of a percent — finer than anything the
|
|
2182
|
+
* bar or the byte readout resolves. Everything else is compared exactly:
|
|
2183
|
+
* `paused` is a state change a person is waiting to see, and the name/index
|
|
2184
|
+
* pair moving means a different FILE, which must never be coalesced away.
|
|
2185
|
+
*/
|
|
2186
|
+
function renderKey(status) {
|
|
2187
|
+
const { name, index, count, loaded, total, fraction, paused } = status;
|
|
2188
|
+
const shown = fraction === void 0 ? loaded : Math.round(fraction * 1e3);
|
|
2189
|
+
return `${paused} ${index} ${count} ${total ?? -1} ${shown} ${name}`;
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* Wrap an `UploadStatus` setter so redundant reports never reach React.
|
|
1886
2193
|
*
|
|
1887
|
-
*
|
|
2194
|
+
* Clearing the status (`undefined`) always passes and resets the comparison —
|
|
2195
|
+
* it ends one file's bar, and the next file must not be coalesced against the
|
|
2196
|
+
* previous one's last frame.
|
|
1888
2197
|
*/
|
|
1889
|
-
function
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
2198
|
+
function coalesceUploadReports(set) {
|
|
2199
|
+
let last;
|
|
2200
|
+
return (status) => {
|
|
2201
|
+
if (status === void 0) {
|
|
2202
|
+
last = void 0;
|
|
2203
|
+
set(void 0);
|
|
1895
2204
|
return;
|
|
1896
2205
|
}
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
objectUrl = URL.createObjectURL(blob);
|
|
1903
|
-
setState({
|
|
1904
|
-
url: objectUrl,
|
|
1905
|
-
pending: false
|
|
1906
|
-
});
|
|
1907
|
-
}).catch((err) => {
|
|
1908
|
-
if (!cancelled) setState({
|
|
1909
|
-
error: errorMessage(err),
|
|
1910
|
-
pending: false
|
|
1911
|
-
});
|
|
1912
|
-
});
|
|
1913
|
-
return () => {
|
|
1914
|
-
cancelled = true;
|
|
1915
|
-
if (objectUrl !== void 0) URL.revokeObjectURL(objectUrl);
|
|
1916
|
-
};
|
|
1917
|
-
}, [uploadId, getClient]);
|
|
1918
|
-
return state;
|
|
2206
|
+
const key = renderKey(status);
|
|
2207
|
+
if (key === last) return;
|
|
2208
|
+
last = key;
|
|
2209
|
+
set(status);
|
|
2210
|
+
};
|
|
1919
2211
|
}
|
|
1920
2212
|
//#endregion
|
|
1921
|
-
//#region use-
|
|
2213
|
+
//#region src/use-workflow-form.ts
|
|
1922
2214
|
/**
|
|
1923
|
-
* The
|
|
1924
|
-
*
|
|
1925
|
-
* `useWorkflowSubmit({ key, recover: true })` is what makes a run survivable —
|
|
1926
|
-
* the run id is that hook's own state, so a refresh loses it while the run
|
|
1927
|
-
* carries on — and the `key` is deliberately the caller's to choose, because it
|
|
1928
|
-
* is a lookup CAPABILITY: there is no per-user filtering behind `find`, so the
|
|
1929
|
-
* key IS the scoping mechanism. Choosing one is easy to get wrong in three
|
|
1930
|
-
* separate ways, and six shipped templates had each written the same twenty
|
|
1931
|
-
* lines to get it right. This is those lines.
|
|
1932
|
-
*
|
|
1933
|
-
* ## Three properties, and the rejected alternatives are why each one matters
|
|
1934
|
-
*
|
|
1935
|
-
* - **Opaque** — `crypto.randomUUID()`, never derived from what was submitted.
|
|
1936
|
-
* A key derived from the input collides the moment two people submit the same
|
|
1937
|
-
* thing, and they then recover each other's runs; it also carries what they
|
|
1938
|
-
* typed into a lookup token the platform deliberately stopped logging.
|
|
1939
|
-
* - **Short.** A `randomUUID` is 36 characters, well inside the 256 that
|
|
1940
|
-
* `POST /workflows/runs` allows a key.
|
|
1941
|
-
* - **Minted once per load and written back for the next one**, which is the
|
|
1942
|
-
* whole mechanism: the load that presses the button records the key with the
|
|
1943
|
-
* run, and the load after it finds the run by producing the same key.
|
|
1944
|
-
*
|
|
1945
|
-
* Storage rather than the page's own URL, for all of them. A `?key=` parameter
|
|
1946
|
-
* survives more (a new tab, a bookmark, a shared link) and that is the problem:
|
|
1947
|
-
* a URL is pasted into chats, copied into referrers and kept in history, and
|
|
1948
|
-
* what a leaked one buys is somebody else's work — reading it, and `cancel()` on
|
|
1949
|
-
* it. An app with accounts should pass the ACCOUNT's own id here instead, and
|
|
1950
|
-
* then a run follows the person to a new device, which is a promise only a login
|
|
1951
|
-
* can keep.
|
|
2215
|
+
* The hook a FORM needs, as against the one a status view does.
|
|
1952
2216
|
*
|
|
1953
|
-
*
|
|
2217
|
+
* `useWorkflowRun` (`workflow-client.ts`) watches a run you already have.
|
|
2218
|
+
* This is what comes before it: `useWorkflowSubmit` starts a run and hands the
|
|
2219
|
+
* id straight to `useWorkflowRun`. Its sibling `useWorkflows` — the listing
|
|
2220
|
+
* `<WorkflowFields>` renders a form from — is `use-workflows.ts`.
|
|
1954
2221
|
*
|
|
1955
|
-
* `
|
|
1956
|
-
* interruption most pages have — a reload, a same-tab navigation, a crashed tab
|
|
1957
|
-
* — and is the same lifetime as this package's other two stores, the session
|
|
1958
|
-
* resume id (`session-resume-store.ts`) and the upload recall
|
|
1959
|
-
* (`_upload-recall.ts`), so both halves of a reload make the same promise.
|
|
2222
|
+
* ## `useWorkflowSubmit` — a form's two halves in one hook
|
|
1960
2223
|
*
|
|
1961
|
-
*
|
|
1962
|
-
*
|
|
1963
|
-
*
|
|
1964
|
-
*
|
|
1965
|
-
*
|
|
1966
|
-
*
|
|
2224
|
+
* A page that submits a workflow always needs the same four pieces of state:
|
|
2225
|
+
* the run id, whether a submit is in flight, whether the RUN is still going, and
|
|
2226
|
+
* whichever of the two failed. `link-digest` writes them out by hand, which is
|
|
2227
|
+
* the right shape for a template teaching the primitives and the wrong shape to
|
|
2228
|
+
* write a third time — and it is easy to get subtly wrong: dropping the previous
|
|
2229
|
+
* run id before the new `POST` returns is what stops a finished result sitting
|
|
2230
|
+
* under a form that is already submitting again.
|
|
1967
2231
|
*
|
|
1968
|
-
*
|
|
2232
|
+
* So this is `api.start` plus {@link useWorkflowRun}, with the state between
|
|
2233
|
+
* them. It adds no transport of its own and holds no run state of its own; the
|
|
2234
|
+
* watching (stream first, poll as its fallback, terminal stops) is entirely
|
|
2235
|
+
* `useWorkflowRun`'s, and `run` here IS its run.
|
|
1969
2236
|
*
|
|
1970
|
-
*
|
|
1971
|
-
* exception: any string is a legal key, so a value from storage can only fail to
|
|
1972
|
-
* match a run. A page that remembers something more — which MODE submitted, say
|
|
1973
|
-
* — is remembering a value it will turn into a name, and storage hands back a
|
|
1974
|
-
* string some earlier version of that page wrote: a renamed mode, a hand-edited
|
|
1975
|
-
* value, a slot another app on the origin happens to share. Unchecked, that
|
|
1976
|
-
* starts a run called `undefined` and answers a 400 nobody typed. Check it
|
|
1977
|
-
* against the page's own list on the way out (`recalledMode` in
|
|
1978
|
-
* `transcription-workflow/recover.ts` is the worked example) — the recall is the
|
|
1979
|
-
* page's, the validation is not optional.
|
|
2237
|
+
* ## Why it starts ASYNCHRONOUSLY even though a synchronous call exists
|
|
1980
2238
|
*
|
|
1981
|
-
*
|
|
2239
|
+
* `api.startAndWait` would collapse this to one request, and it is the wrong
|
|
2240
|
+
* default for a page: it holds a socket open for up to a minute, answers nothing
|
|
2241
|
+
* until it settles, and a page has `useWorkflowRun` — which survives a reload,
|
|
2242
|
+
* shows progress, and costs one stream. The synchronous call is for callers with
|
|
2243
|
+
* nowhere to put a watch (a script, a cron, a form POST from a server). Pass
|
|
2244
|
+
* `wait` here when the page really does want one request, and the run is
|
|
2245
|
+
* followed from the same id either way.
|
|
2246
|
+
*/
|
|
2247
|
+
/**
|
|
2248
|
+
* Start a workflow from a form, and follow the run it creates.
|
|
1982
2249
|
*
|
|
1983
|
-
*
|
|
1984
|
-
*
|
|
1985
|
-
*
|
|
1986
|
-
* drops the query and the hash, since a reload carrying `?foo` or `#bar` has to
|
|
1987
|
-
* find the same key. Same call `session-resume-store.ts` makes, for the same
|
|
1988
|
-
* reason.
|
|
2250
|
+
* @typeParam D - The workflow DEFINITION, which types both halves of the
|
|
2251
|
+
* submission: `submit(input)` takes what the workflow's schema parses to, and
|
|
2252
|
+
* `run.status === "completed"` narrows to a typed `run.output`.
|
|
1989
2253
|
*
|
|
1990
|
-
*
|
|
1991
|
-
*
|
|
1992
|
-
*
|
|
2254
|
+
* It used to be the OUTPUT type alone, and the asymmetry was the bug: a page
|
|
2255
|
+
* already wrote `WorkflowOutputOf<typeof digest>` to get the output, while
|
|
2256
|
+
* `submit` took `unknown`, so `submit({ ur1: 42 })` compiled and arrived as a
|
|
2257
|
+
* 400 in the browser. Naming the def instead types the input from the same
|
|
2258
|
+
* declaration — and `import type` is ERASED, so it costs the bundle nothing.
|
|
2259
|
+
* Passing an output type where a def belongs is now a compile error rather
|
|
2260
|
+
* than a silent loss of typing, which is the point.
|
|
1993
2261
|
*
|
|
1994
|
-
*
|
|
1995
|
-
*
|
|
1996
|
-
*
|
|
1997
|
-
*
|
|
1998
|
-
* failing to render.
|
|
1999
|
-
*/
|
|
2000
|
-
/** Where a run key lives, namespaced like this package's two other stores. */
|
|
2001
|
-
const PREFIX = "aai:run-key:";
|
|
2002
|
-
/** This page's own slot — see "The slot is keyed by the page's own URL". */
|
|
2003
|
-
function slotFor() {
|
|
2004
|
-
const href = globalThis.location?.href;
|
|
2005
|
-
if (href === void 0) return PREFIX;
|
|
2006
|
-
try {
|
|
2007
|
-
return `${PREFIX}${new URL("./", href).href}`;
|
|
2008
|
-
} catch {
|
|
2009
|
-
return `${PREFIX}${href}`;
|
|
2010
|
-
}
|
|
2011
|
-
}
|
|
2012
|
-
/**
|
|
2013
|
-
* Read the key this page already has, or mint and remember one.
|
|
2262
|
+
* @example
|
|
2263
|
+
* ```tsx no-check
|
|
2264
|
+
* import { Form, SubmitButton, TextField, useWorkflowSubmit } from "@alexkroman1/aai-ui";
|
|
2265
|
+
* import type { digest } from "./agent.ts";
|
|
2014
2266
|
*
|
|
2015
|
-
*
|
|
2016
|
-
*
|
|
2017
|
-
*
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
store?.setItem(slot, minted);
|
|
2027
|
-
return minted;
|
|
2028
|
-
} catch {
|
|
2029
|
-
return crypto.randomUUID();
|
|
2030
|
-
}
|
|
2031
|
-
}
|
|
2032
|
-
/**
|
|
2033
|
-
* A lookup key for `useWorkflowSubmit({ key, recover: true })`, stable across
|
|
2034
|
-
* reloads.
|
|
2267
|
+
* function DigestForm() {
|
|
2268
|
+
* const { submit, run, pending, error } = useWorkflowSubmit<typeof digest>("digest");
|
|
2269
|
+
* return (
|
|
2270
|
+
* <Form onSubmit={(values) => submit(values)} error={error}>
|
|
2271
|
+
* <TextField name="url" label="Link" type="url" required />
|
|
2272
|
+
* <SubmitButton pending={pending}>Digest</SubmitButton>
|
|
2273
|
+
* {run?.status === "completed" && <p>{run.output.title}</p>}
|
|
2274
|
+
* </Form>
|
|
2275
|
+
* );
|
|
2276
|
+
* }
|
|
2277
|
+
* ```
|
|
2035
2278
|
*
|
|
2036
|
-
* @
|
|
2037
|
-
* is read once, when the key is minted: a value that changed afterwards would
|
|
2038
|
-
* be asking to move a key that has already been recorded with a run.
|
|
2039
|
-
* @returns The key to record runs under and to look them up by — the same one
|
|
2040
|
-
* for the life of the component, and for the next load in the same tab (or the
|
|
2041
|
-
* same browser, under `"local"`).
|
|
2279
|
+
* @public
|
|
2042
2280
|
*/
|
|
2043
|
-
function
|
|
2044
|
-
const {
|
|
2045
|
-
const
|
|
2046
|
-
|
|
2281
|
+
function useWorkflowSubmit(workflow, opts = {}) {
|
|
2282
|
+
const { api, recover = true, wait, intervalMs, parallel } = opts;
|
|
2283
|
+
const key = useDefaultRunKey(opts.key);
|
|
2284
|
+
const state = useSubmissionState();
|
|
2285
|
+
const { runId, actions } = state;
|
|
2286
|
+
const getClient = useWorkflowApiRef(api);
|
|
2287
|
+
const tracked = useWorkflowRun(runId, omitUndefined({
|
|
2288
|
+
api,
|
|
2289
|
+
intervalMs
|
|
2290
|
+
}));
|
|
2291
|
+
const { wake, cancel } = useRunControls(runId, getClient);
|
|
2292
|
+
const [startedHere, setStartedHere] = useState(false);
|
|
2293
|
+
const recovering = useRecoveredRun({
|
|
2294
|
+
workflow,
|
|
2295
|
+
key,
|
|
2296
|
+
enabled: recover,
|
|
2297
|
+
getClient,
|
|
2298
|
+
onFound: (found) => {
|
|
2299
|
+
actions.setRunId((current) => current ?? found);
|
|
2300
|
+
},
|
|
2301
|
+
onError: actions.setStartError
|
|
2302
|
+
});
|
|
2303
|
+
const submit = useCallback(async (input) => {
|
|
2304
|
+
const client = getClient();
|
|
2305
|
+
const current = createUploadSession(workflow);
|
|
2306
|
+
setStartedHere(true);
|
|
2307
|
+
actions.begin(current);
|
|
2308
|
+
try {
|
|
2309
|
+
const options = omitUndefined({ key });
|
|
2310
|
+
const started = await uploadFiles(client, input, coalesceUploadReports(actions.setUpload), parallel, current);
|
|
2311
|
+
actions.setRunId(wait === void 0 ? await client.start(workflow, started, options) : (await client.startAndWait(workflow, started, {
|
|
2312
|
+
...options,
|
|
2313
|
+
wait
|
|
2314
|
+
})).runId);
|
|
2315
|
+
} catch (err) {
|
|
2316
|
+
if (!current.gate.cancelled) actions.setStartError(errorMessage(err));
|
|
2317
|
+
} finally {
|
|
2318
|
+
actions.end(current);
|
|
2319
|
+
}
|
|
2320
|
+
}, [
|
|
2321
|
+
workflow,
|
|
2322
|
+
key,
|
|
2323
|
+
wait,
|
|
2324
|
+
parallel,
|
|
2325
|
+
getClient,
|
|
2326
|
+
actions
|
|
2327
|
+
]);
|
|
2328
|
+
return {
|
|
2329
|
+
submit,
|
|
2330
|
+
submitForm: submit,
|
|
2331
|
+
reset: useCallback(() => {
|
|
2332
|
+
setStartedHere(false);
|
|
2333
|
+
actions.reset();
|
|
2334
|
+
}, [actions]),
|
|
2335
|
+
wake,
|
|
2336
|
+
cancel,
|
|
2337
|
+
pauseUpload: actions.pauseUpload,
|
|
2338
|
+
resumeUpload: actions.resumeUpload,
|
|
2339
|
+
run: tracked.run,
|
|
2340
|
+
startedHere,
|
|
2341
|
+
pending: recovering || state.starting || tracked.polling,
|
|
2342
|
+
upload: state.upload,
|
|
2343
|
+
error: state.startError ?? tracked.error
|
|
2344
|
+
};
|
|
2047
2345
|
}
|
|
2048
2346
|
//#endregion
|
|
2049
|
-
//#region use-workflow-runs.ts
|
|
2347
|
+
//#region src/use-workflow-runs.ts
|
|
2050
2348
|
/**
|
|
2051
2349
|
* The RUNS a workflow has had — the list a page shows beside its form.
|
|
2052
2350
|
*
|
|
@@ -2144,7 +2442,7 @@ function useWorkflowRuns(workflow, opts = {}) {
|
|
|
2144
2442
|
};
|
|
2145
2443
|
}
|
|
2146
2444
|
//#endregion
|
|
2147
|
-
//#region use-workflow-stream.ts
|
|
2445
|
+
//#region src/use-workflow-stream.ts
|
|
2148
2446
|
/**
|
|
2149
2447
|
* Starting a run BEFORE its file has finished uploading.
|
|
2150
2448
|
*
|
|
@@ -2172,7 +2470,7 @@ function useWorkflowRuns(workflow, opts = {}) {
|
|
|
2172
2470
|
* earlier version of this hook took a `cut` callback and uploaded N parts into a
|
|
2173
2471
|
* "group" that a separate call had to seal; it worked, and every piece of it was
|
|
2174
2472
|
* something the caller had to get right. The whole of that is replaced by the store
|
|
2175
|
-
* publishing `size` as bytes land — which `
|
|
2473
|
+
* publishing `size` as bytes land — which `stepReadUpload` already clamped to — so the
|
|
2176
2474
|
* run does exactly what it does over a finished file and simply waits for windows to
|
|
2177
2475
|
* become present.
|
|
2178
2476
|
*
|
|
@@ -2250,25 +2548,21 @@ function useWorkflowRuns(workflow, opts = {}) {
|
|
|
2250
2548
|
*/
|
|
2251
2549
|
function useWorkflowStream(workflow, opts = {}) {
|
|
2252
2550
|
const { api, key, intervalMs, parallel } = opts;
|
|
2253
|
-
const
|
|
2254
|
-
const
|
|
2255
|
-
const [startError, setStartError] = useState(void 0);
|
|
2256
|
-
const [upload, setUpload] = useState(void 0);
|
|
2257
|
-
const gateRef = useRef(void 0);
|
|
2551
|
+
const state = useSubmissionState();
|
|
2552
|
+
const { runId, actions } = state;
|
|
2258
2553
|
const getClient = useWorkflowApiRef(api);
|
|
2259
2554
|
const tracked = useWorkflowRun(runId, omitUndefined({
|
|
2260
2555
|
api,
|
|
2261
2556
|
intervalMs
|
|
2262
2557
|
}));
|
|
2263
2558
|
const { wake, cancel } = useRunControls(runId, getClient);
|
|
2559
|
+
const [startedHere, setStartedHere] = useState(false);
|
|
2264
2560
|
const submit = useCallback(async (input) => {
|
|
2265
2561
|
const client = getClient();
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
const gate = createUploadGate();
|
|
2271
|
-
gateRef.current = gate;
|
|
2562
|
+
const current = { gate: createUploadGate() };
|
|
2563
|
+
const { gate } = current;
|
|
2564
|
+
setStartedHere(true);
|
|
2565
|
+
actions.begin(current);
|
|
2272
2566
|
let started;
|
|
2273
2567
|
try {
|
|
2274
2568
|
const id = randomUploadId();
|
|
@@ -2281,7 +2575,7 @@ function useWorkflowStream(workflow, opts = {}) {
|
|
|
2281
2575
|
});
|
|
2282
2576
|
started = begun.runId;
|
|
2283
2577
|
const chosen = begun.file;
|
|
2284
|
-
setRunId(started);
|
|
2578
|
+
actions.setRunId(started);
|
|
2285
2579
|
if (!chosen) return;
|
|
2286
2580
|
await streamFile({
|
|
2287
2581
|
client,
|
|
@@ -2289,45 +2583,38 @@ function useWorkflowStream(workflow, opts = {}) {
|
|
|
2289
2583
|
id,
|
|
2290
2584
|
file: chosen,
|
|
2291
2585
|
parallel,
|
|
2292
|
-
report: setUpload
|
|
2586
|
+
report: coalesceUploadReports(actions.setUpload)
|
|
2293
2587
|
});
|
|
2294
2588
|
await client.wake(started).catch(() => void 0);
|
|
2295
2589
|
} catch (err) {
|
|
2296
|
-
if (!gate.cancelled) setStartError(errorMessage(err));
|
|
2590
|
+
if (!gate.cancelled) actions.setStartError(errorMessage(err));
|
|
2297
2591
|
if (started) await client.cancel(started).catch(() => void 0);
|
|
2298
2592
|
} finally {
|
|
2299
|
-
|
|
2300
|
-
gateRef.current = void 0;
|
|
2301
|
-
setStarting(false);
|
|
2302
|
-
setUpload(void 0);
|
|
2303
|
-
}
|
|
2593
|
+
actions.end(current);
|
|
2304
2594
|
}
|
|
2305
2595
|
}, [
|
|
2306
2596
|
workflow,
|
|
2307
2597
|
key,
|
|
2308
2598
|
parallel,
|
|
2309
|
-
getClient
|
|
2599
|
+
getClient,
|
|
2600
|
+
actions
|
|
2310
2601
|
]);
|
|
2311
|
-
const reset = useCallback(() => {
|
|
2312
|
-
gateRef.current?.cancel();
|
|
2313
|
-
gateRef.current = void 0;
|
|
2314
|
-
setRunId(void 0);
|
|
2315
|
-
setStartError(void 0);
|
|
2316
|
-
setUpload(void 0);
|
|
2317
|
-
}, []);
|
|
2318
|
-
const { pauseUpload, resumeUpload } = useUploadPause(useCallback(() => gateRef.current, []), setUpload);
|
|
2319
2602
|
return {
|
|
2320
2603
|
submit,
|
|
2321
2604
|
submitForm: submit,
|
|
2322
|
-
reset
|
|
2605
|
+
reset: useCallback(() => {
|
|
2606
|
+
setStartedHere(false);
|
|
2607
|
+
actions.reset();
|
|
2608
|
+
}, [actions]),
|
|
2323
2609
|
wake,
|
|
2324
2610
|
cancel,
|
|
2325
|
-
pauseUpload,
|
|
2326
|
-
resumeUpload,
|
|
2611
|
+
pauseUpload: actions.pauseUpload,
|
|
2612
|
+
resumeUpload: actions.resumeUpload,
|
|
2327
2613
|
run: tracked.run,
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2614
|
+
startedHere,
|
|
2615
|
+
pending: state.starting || tracked.polling,
|
|
2616
|
+
upload: state.upload,
|
|
2617
|
+
error: state.startError ?? tracked.error
|
|
2331
2618
|
};
|
|
2332
2619
|
}
|
|
2333
2620
|
/**
|
|
@@ -2423,7 +2710,7 @@ function fileAt(input, field) {
|
|
|
2423
2710
|
return filesOf(input[field])[0];
|
|
2424
2711
|
}
|
|
2425
2712
|
//#endregion
|
|
2426
|
-
//#region workflow-status-labels.ts
|
|
2713
|
+
//#region src/workflow-status-labels.ts
|
|
2427
2714
|
/**
|
|
2428
2715
|
* The default status line per {@link WorkflowRunStatus}.
|
|
2429
2716
|
*
|
|
@@ -2449,4 +2736,4 @@ const WORKFLOW_STATUS_LABELS = {
|
|
|
2449
2736
|
cancelled: "Cancelled"
|
|
2450
2737
|
};
|
|
2451
2738
|
//#endregion
|
|
2452
|
-
export { AutoScroll, Button, ChatView, CheckboxField, ConsoleShell, Controls, Field, FileField, Form, Markdown, MessageList, NumberField, SelectField, SidebarLayout, StartScreen, SubmitButton, TextAreaField, TextField, ToolCallRow, UploadProgressBar, WORKFLOW_STATUS_LABELS, WorkflowFields, WorkflowProgress,
|
|
2739
|
+
export { AGENT_STATE_LABELS, AutoScroll, BulletList, Button, ChatView, CheckboxField, ConsoleShell, Controls, Facts, Field, FileField, Form, Markdown, MessageList, NumberField, SelectField, SessionErrorBanner, SidebarLayout, StartScreen, SubmitButton, TextAreaField, TextField, ToolCallRow, UploadProgressBar, WORKFLOW_STATUS_LABELS, WorkflowFields, WorkflowProgress, createBrowserSession, createWorkflowApi, fetchClientConfig, isTerminal, mountClient, mountPage, useAgentState, useConversation, useDownloadUrl, useEvent, useRunKey, useSession, useSessionActions, useSessionError, useSessionSelector, useSessionStatus, useTheme, useToolCallStart, useToolResult, useUserTranscript, useWorkflowProgress, useWorkflowRun, useWorkflowRuns, useWorkflowStream, useWorkflowSubmit, useWorkflows };
|