@alexkroman1/aai-ui 13.2.0 → 13.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{_colors-j8XMToi9.js → _colors-CZ6OlPbL.js} +1 -1
- package/dist/{_module-url-C_4gRVL0.js → _module-url-C13kAJ87.js} +1 -1
- package/dist/_recover-run.d.ts +11 -5
- package/dist/{_utils-B6498_bm.js → _utils-CyzjK0gW.js} +1 -1
- package/dist/{aai-logo-CXZGPSIY.js → aai-logo-CFlomZlS.js} +1 -1
- package/dist/audio.js +1 -1
- package/dist/{chat-view-DDTtrh7N.js → chat-view-Bv5VFJIE.js} +6 -6
- package/dist/{client-config-BT_kWID5.js → client-config-DD820zHn.js} +1 -1
- package/dist/client-dir.js +1 -1
- package/dist/components/button.js +2 -2
- package/dist/components/chat-view.js +1 -1
- package/dist/components/controls.js +2 -2
- package/dist/components/message-list.js +1 -1
- 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/context.js +1 -1
- package/dist/default-client.d.ts +1 -1
- package/dist/define-client.js +6 -6
- package/dist/{eyebrow-C6ZFuiz6.js → eyebrow-UfmSz9yy.js} +1 -1
- package/dist/hooks.js +2 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1168 -1114
- package/dist/internal.js +5 -5
- package/dist/{message-list-BJYyuIcR.js → message-list-C0pL7x41.js} +7 -7
- package/dist/{session-core-DxBYsfHA.js → session-core-C9elBIdu.js} +11 -11
- package/dist/session-core.js +1 -1
- package/dist/{tool-call-block-tcPQAkcP.js → tool-call-block-Bunc6rCw.js} +6 -6
- package/dist/{tool-config-context-DzAofqi_.js → tool-config-context-Bh8p3DtG.js} +1 -1
- package/dist/types.js +1 -1
- package/dist/{url-chips-YqhCjWfQ.js → url-chips-C2u7QPv8.js} +3 -3
- package/dist/use-run-key.d.ts +44 -10
- package/dist/{use-user-transcript-C14qWFu2.js → use-user-transcript-DFTSEuZN.js} +1 -1
- package/dist/use-workflow-form.d.ts +31 -90
- package/dist/{use-workflow-progress-Cu0SxMyg.js → use-workflow-run-CXGEcM0l.js} +256 -256
- package/dist/use-workflow-stream.d.ts +4 -1
- package/dist/use-workflows.d.ts +77 -0
- package/dist/worklets/capture-processor.js +2 -2
- package/dist/worklets/playback-processor.js +2 -2
- package/package.json +6 -6
- package/dist/tsdown.config.d.ts +0 -2
package/dist/index.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
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-
|
|
1
|
+
import { n as fetchClientConfig } from "./client-config-DD820zHn.js";
|
|
2
|
+
import { i as AutoScroll, n as Markdown, r as useConversation, t as MessageList } from "./message-list-C0pL7x41.js";
|
|
3
3
|
import { ThemeProvider, useSession, useSessionSelector, useTheme } from "./context.js";
|
|
4
|
-
import { r as inkTint } from "./_colors-
|
|
4
|
+
import { r as inkTint } from "./_colors-CZ6OlPbL.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, t as ChatView } from "./chat-view-Bv5VFJIE.js";
|
|
7
|
+
import { n as setPageTitle } from "./_utils-CyzjK0gW.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-DFTSEuZN.js";
|
|
10
|
+
import { n as ToolCallRow } from "./tool-call-block-Bunc6rCw.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 createSessionCore } from "./session-core-
|
|
13
|
+
import { a as useWorkflowProgress, c as isTerminal, o as useWorkflowApiRef, r as useWorkflowRun, s as createWorkflowApi } from "./use-workflow-run-CXGEcM0l.js";
|
|
14
|
+
import { t as createSessionCore } from "./session-core-C9elBIdu.js";
|
|
15
15
|
import { client, mountRoot, resolveContainer } from "./define-client.js";
|
|
16
16
|
import { useAgentState, useEvent, useToolCallStart, useToolResult } from "./hooks.js";
|
|
17
17
|
import clsx from "clsx";
|
|
@@ -20,7 +20,7 @@ import { createContext, createElement, useCallback, useContext, useEffect, useId
|
|
|
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 components/_form-readiness.ts
|
|
23
|
+
//#region src/components/_form-readiness.ts
|
|
24
24
|
/**
|
|
25
25
|
* Whether a form's own fields exist yet.
|
|
26
26
|
*
|
|
@@ -106,7 +106,7 @@ function useDeclareFieldsPending(pending) {
|
|
|
106
106
|
]);
|
|
107
107
|
}
|
|
108
108
|
//#endregion
|
|
109
|
-
//#region components/_form-values.ts
|
|
109
|
+
//#region src/components/_form-values.ts
|
|
110
110
|
/**
|
|
111
111
|
* Read one `<form>`'s named controls into a plain object.
|
|
112
112
|
*
|
|
@@ -201,7 +201,7 @@ function dataUrl(file) {
|
|
|
201
201
|
});
|
|
202
202
|
}
|
|
203
203
|
//#endregion
|
|
204
|
-
//#region components/form-fields.tsx
|
|
204
|
+
//#region src/components/form-fields.tsx
|
|
205
205
|
/** @jsxImportSource react */
|
|
206
206
|
/**
|
|
207
207
|
* The controls a form is made of — the shell, the six fields, and the styling
|
|
@@ -512,7 +512,7 @@ function FileField({ name, label, hint, className, read = "none", upload = false
|
|
|
512
512
|
});
|
|
513
513
|
}
|
|
514
514
|
//#endregion
|
|
515
|
-
//#region components/form.tsx
|
|
515
|
+
//#region src/components/form.tsx
|
|
516
516
|
/** @jsxImportSource react */
|
|
517
517
|
/**
|
|
518
518
|
* Simple forms, for the pages that are not conversations.
|
|
@@ -654,7 +654,7 @@ function SubmitButton({ children, pending = false, pendingLabel = "Working…",
|
|
|
654
654
|
});
|
|
655
655
|
}
|
|
656
656
|
//#endregion
|
|
657
|
-
//#region components/upload-progress.tsx
|
|
657
|
+
//#region src/components/upload-progress.tsx
|
|
658
658
|
/** @jsxImportSource react */
|
|
659
659
|
/**
|
|
660
660
|
* The track behind the fill, as a tint of the theme's own ink.
|
|
@@ -766,1287 +766,1341 @@ function UploadProgressBar({ upload, onPause, onResume, className }) {
|
|
|
766
766
|
});
|
|
767
767
|
}
|
|
768
768
|
//#endregion
|
|
769
|
-
//#region
|
|
769
|
+
//#region src/use-workflows.ts
|
|
770
770
|
/**
|
|
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
|
|
771
|
+
* The agent's declared workflows — what a form is rendered FROM.
|
|
794
772
|
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
773
|
+
* Split out of `use-workflow-form.ts` at the 500-line cap, along the seam that
|
|
774
|
+
* file's own doc already drew: it held "the two hooks a FORM needs", and only
|
|
775
|
+
* one of them is about a RUN. This is the other one, and it shares nothing with
|
|
776
|
+
* its former neighbours but the client ref every hook here uses — no state, no
|
|
777
|
+
* run id, no upload.
|
|
778
|
+
*/
|
|
779
|
+
/**
|
|
780
|
+
* Read the agent's declared workflows.
|
|
799
781
|
*
|
|
800
|
-
*
|
|
782
|
+
* What `<WorkflowFields>` renders a form FROM: each summary carries the JSON
|
|
783
|
+
* Schema of that workflow's input, converted server-side precisely so a browser
|
|
784
|
+
* can read it.
|
|
801
785
|
*
|
|
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.
|
|
786
|
+
* The failure is reported rather than swallowed, because the alternative is an
|
|
787
|
+
* empty list — which renders as a form with no fields and reads as "this agent
|
|
788
|
+
* declares no workflows" about an agent that was merely unreachable.
|
|
808
789
|
*
|
|
809
|
-
*
|
|
790
|
+
* @example
|
|
791
|
+
* ```tsx
|
|
792
|
+
* import { useWorkflows } from "@alexkroman1/aai-ui";
|
|
810
793
|
*
|
|
811
|
-
* A
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
*
|
|
794
|
+
* // A page rendering its own chrome from the listing — a picker, say. A form
|
|
795
|
+
* // for ONE workflow wants `<WorkflowFields workflow="name" />` instead,
|
|
796
|
+
* // which does this lookup itself.
|
|
797
|
+
* function WorkflowPicker({ onPick }: { onPick: (name: string) => void }) {
|
|
798
|
+
* const { workflows, loading, error } = useWorkflows();
|
|
799
|
+
* if (loading) return <p>Loading…</p>;
|
|
800
|
+
* if (error !== undefined) return <p role="alert">{error}</p>;
|
|
801
|
+
* return (
|
|
802
|
+
* <ul>
|
|
803
|
+
* {workflows.map((summary) => (
|
|
804
|
+
* <li key={summary.name}>
|
|
805
|
+
* <button type="button" onClick={() => onPick(summary.name)}>
|
|
806
|
+
* {summary.description ?? summary.name}
|
|
807
|
+
* </button>
|
|
808
|
+
* </li>
|
|
809
|
+
* ))}
|
|
810
|
+
* </ul>
|
|
811
|
+
* );
|
|
812
|
+
* }
|
|
813
|
+
* ```
|
|
818
814
|
*
|
|
819
|
-
* @param opts - See {@link
|
|
820
|
-
* @returns
|
|
821
|
-
*
|
|
822
|
-
* form inviting a second one.
|
|
815
|
+
* @param opts - See {@link UseWorkflowsOptions}.
|
|
816
|
+
* @returns The listing, its loading flag and its failure — see
|
|
817
|
+
* {@link UseWorkflowsResult}.
|
|
823
818
|
*
|
|
824
|
-
* @
|
|
819
|
+
* @public
|
|
825
820
|
*/
|
|
826
|
-
function
|
|
827
|
-
const {
|
|
828
|
-
const [
|
|
829
|
-
|
|
830
|
-
|
|
821
|
+
function useWorkflows(opts = {}) {
|
|
822
|
+
const { api, skip = false } = opts;
|
|
823
|
+
const [state, setState] = useState({
|
|
824
|
+
workflows: [],
|
|
825
|
+
loading: !skip,
|
|
826
|
+
error: void 0
|
|
827
|
+
});
|
|
828
|
+
const getClient = useWorkflowApiRef(api);
|
|
831
829
|
useEffect(() => {
|
|
832
|
-
if (
|
|
830
|
+
if (skip) return;
|
|
833
831
|
let cancelled = false;
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
832
|
+
getClient().list().then((workflows) => {
|
|
833
|
+
if (!cancelled) setState({
|
|
834
|
+
workflows,
|
|
835
|
+
loading: false,
|
|
836
|
+
error: void 0
|
|
837
|
+
});
|
|
840
838
|
}).catch((err) => {
|
|
841
839
|
if (cancelled) return;
|
|
842
|
-
|
|
843
|
-
|
|
840
|
+
setState({
|
|
841
|
+
workflows: [],
|
|
842
|
+
loading: false,
|
|
843
|
+
error: errorMessage(err)
|
|
844
|
+
});
|
|
844
845
|
});
|
|
845
846
|
return () => {
|
|
846
847
|
cancelled = true;
|
|
847
848
|
};
|
|
848
|
-
}, [
|
|
849
|
-
|
|
850
|
-
key,
|
|
851
|
-
workflow,
|
|
852
|
-
getClient
|
|
853
|
-
]);
|
|
854
|
-
return recovering;
|
|
849
|
+
}, [skip, getClient]);
|
|
850
|
+
return state;
|
|
855
851
|
}
|
|
856
852
|
//#endregion
|
|
857
|
-
//#region
|
|
853
|
+
//#region src/components/workflow-fields.tsx
|
|
854
|
+
/** @jsxImportSource react */
|
|
858
855
|
/**
|
|
859
|
-
*
|
|
860
|
-
*
|
|
861
|
-
* `useWorkflowSubmit` and `useWorkflowStream` both hold a run id and neither
|
|
862
|
-
* handed it back, so a page that wanted "send it now" or "stop" had to hold an
|
|
863
|
-
* `api` of its own purely to write `api.wake(runId)` — which is the whole reason
|
|
864
|
-
* the two raw-primitive template pages keep a client at module scope. That is a
|
|
865
|
-
* page carrying the transport to make up for a hook withholding its own state.
|
|
856
|
+
* A form built from a workflow's declared input schema.
|
|
866
857
|
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
*
|
|
871
|
-
*
|
|
872
|
-
*/
|
|
873
|
-
/**
|
|
874
|
-
* Bind `wake` and `cancel` to whatever run the hook is currently following.
|
|
858
|
+
* `GET workflows` reports each workflow's `inputSchema` as JSON Schema — the
|
|
859
|
+
* zod schema an author wrote in `agent.ts`, converted at listing time precisely
|
|
860
|
+
* so a browser can read it. This is what reads it: one `<WorkflowFields>` and a
|
|
861
|
+
* workflow's form matches its schema by construction, so adding a field to the
|
|
862
|
+
* schema adds it to the page and nothing can drift.
|
|
875
863
|
*
|
|
876
|
-
*
|
|
877
|
-
* @param getClient - The stable getter from `useWorkflowApiRef`.
|
|
878
|
-
* @returns Two callbacks, stable while `runId` is.
|
|
864
|
+
* ## It covers SCALARS, and says so rather than guessing
|
|
879
865
|
*
|
|
880
|
-
*
|
|
866
|
+
* A string, number, integer, boolean or enum has one obvious control each. A
|
|
867
|
+
* nested object or an array does not — every choice (a JSON textarea, a repeater,
|
|
868
|
+
* a comma-separated string) is a guess about what the author meant, and a guess
|
|
869
|
+
* that produces a value the schema then rejects is worse than no field at all.
|
|
870
|
+
* So those are SKIPPED, and the fields for them are written by hand: every field
|
|
871
|
+
* in this package is a plain named control, so a hand-written one composes with
|
|
872
|
+
* a generated one inside the same {@link Form}.
|
|
881
873
|
*/
|
|
882
|
-
function useRunControls(runId, getClient) {
|
|
883
|
-
return {
|
|
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
874
|
/**
|
|
897
|
-
*
|
|
898
|
-
*
|
|
899
|
-
* A streamed upload is resumable because its id outlives the attempt that began
|
|
900
|
-
* it — `_upload-files.ts` says so, and `_upload-session.ts` turns that into a
|
|
901
|
-
* pause a person can press. Both of them hold the id in MEMORY: the walk's
|
|
902
|
-
* `UploadSession` lives in a `useRef`, so a reload was the one interruption the
|
|
903
|
-
* mechanism could not survive. Everything else was already in place — the windows
|
|
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.
|
|
875
|
+
* Render one field per scalar property of a workflow's input schema.
|
|
913
876
|
*
|
|
914
|
-
*
|
|
877
|
+
* Pass the workflow's NAME and the schema is fetched here; pass a
|
|
878
|
+
* {@link WorkflowSummary} you already hold and nothing is fetched. The name form
|
|
879
|
+
* is the one a page usually wants — it is the same string the submit hook takes,
|
|
880
|
+
* and the alternative is three lines (`useWorkflows()`, a `.find()` by name, and
|
|
881
|
+
* folding that lookup's error into the form's) whose only product is this
|
|
882
|
+
* component's argument.
|
|
915
883
|
*
|
|
916
|
-
*
|
|
917
|
-
*
|
|
918
|
-
*
|
|
919
|
-
*
|
|
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.
|
|
884
|
+
* Renders nothing when the workflow declared no schema — a workflow with no
|
|
885
|
+
* declared input takes anything, and a form for "anything" is not a form — and
|
|
886
|
+
* nothing while a named lookup is still in flight, so the hand-written fields
|
|
887
|
+
* beside it are not reordered when the schema lands.
|
|
922
888
|
*
|
|
923
|
-
*
|
|
889
|
+
* @example
|
|
890
|
+
* ```tsx no-check
|
|
891
|
+
* import { Form, SubmitButton, WorkflowFields, useWorkflowSubmit }
|
|
892
|
+
* from "@alexkroman1/aai-ui";
|
|
893
|
+
* import type { transcribe } from "./agent.ts";
|
|
924
894
|
*
|
|
925
|
-
*
|
|
926
|
-
*
|
|
927
|
-
*
|
|
928
|
-
*
|
|
929
|
-
*
|
|
895
|
+
* function StartRun() {
|
|
896
|
+
* const { submitForm, pending, error } = useWorkflowSubmit<typeof transcribe>("transcribe");
|
|
897
|
+
* return (
|
|
898
|
+
* <Form onSubmit={submitForm} error={error}>
|
|
899
|
+
* <WorkflowFields workflow="transcribe" />
|
|
900
|
+
* <SubmitButton pending={pending}>Transcribe</SubmitButton>
|
|
901
|
+
* </Form>
|
|
902
|
+
* );
|
|
903
|
+
* }
|
|
904
|
+
* ```
|
|
930
905
|
*
|
|
931
|
-
*
|
|
932
|
-
* under a blocking policy, and an upload that cannot be REMEMBERED must degrade
|
|
933
|
-
* to the upload we would have done anyway rather than failing to start.
|
|
934
|
-
*/
|
|
935
|
-
const PREFIX$1 = "aai:upload:";
|
|
936
|
-
/**
|
|
937
|
-
* How many ids one form keeps.
|
|
906
|
+
* @param props - Field-set props.
|
|
938
907
|
*
|
|
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.
|
|
908
|
+
* @public
|
|
943
909
|
*/
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
910
|
+
function WorkflowFields({ workflow }) {
|
|
911
|
+
const { workflows, loading } = useWorkflows(typeof workflow === "string" ? {} : { skip: true });
|
|
912
|
+
const summary = typeof workflow === "string" ? workflows.find((entry) => entry.name === workflow) : workflow;
|
|
913
|
+
useDeclareFieldsPending(loading);
|
|
914
|
+
const schema = asObjectSchema(summary?.inputSchema);
|
|
915
|
+
if (!schema?.properties) return null;
|
|
916
|
+
const required = new Set(schema.required ?? []);
|
|
917
|
+
const uploads = new Set(summary?.uploads ?? []);
|
|
918
|
+
return /* @__PURE__ */ jsx(Fragment, { children: Object.entries(schema.properties).map(([name, property]) => /* @__PURE__ */ jsx(SchemaField, {
|
|
919
|
+
name,
|
|
920
|
+
property,
|
|
921
|
+
required: required.has(name),
|
|
922
|
+
upload: uploads.has(name)
|
|
923
|
+
}, name)) });
|
|
948
924
|
}
|
|
949
|
-
/**
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
*/
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
925
|
+
/** One property's control, or nothing when its type has no obvious one. */
|
|
926
|
+
function SchemaField({ name, property, required, upload = false }) {
|
|
927
|
+
const label = humanize(name);
|
|
928
|
+
const hint = property.description === void 0 ? {} : { hint: property.description };
|
|
929
|
+
const defaults = property.default === void 0 ? {} : { defaultValue: String(property.default) };
|
|
930
|
+
if (upload) return /* @__PURE__ */ jsx(FileField, {
|
|
931
|
+
name,
|
|
932
|
+
label,
|
|
933
|
+
required,
|
|
934
|
+
upload: true,
|
|
935
|
+
...hint
|
|
936
|
+
});
|
|
937
|
+
if (Array.isArray(property.enum) && property.enum.length > 0) return /* @__PURE__ */ jsx(SelectField, {
|
|
938
|
+
name,
|
|
939
|
+
label,
|
|
940
|
+
required,
|
|
941
|
+
options: property.enum.map((value) => String(value)),
|
|
942
|
+
...hint,
|
|
943
|
+
...defaults
|
|
944
|
+
});
|
|
945
|
+
const type = typeOf(property);
|
|
946
|
+
switch (type) {
|
|
947
|
+
case "boolean": return /* @__PURE__ */ jsx(CheckboxField, {
|
|
948
|
+
name,
|
|
949
|
+
label,
|
|
950
|
+
defaultChecked: property.default === true,
|
|
951
|
+
...hint
|
|
952
|
+
});
|
|
953
|
+
case "number":
|
|
954
|
+
case "integer": return /* @__PURE__ */ jsx(NumberField, {
|
|
955
|
+
name,
|
|
956
|
+
label,
|
|
957
|
+
required,
|
|
958
|
+
step: type === "integer" ? 1 : "any",
|
|
959
|
+
...hint,
|
|
960
|
+
...defaults
|
|
961
|
+
});
|
|
962
|
+
case "string": return /* @__PURE__ */ jsx(TextField, {
|
|
963
|
+
name,
|
|
964
|
+
label,
|
|
965
|
+
required,
|
|
966
|
+
...hint,
|
|
967
|
+
...defaults
|
|
968
|
+
});
|
|
969
|
+
default: return null;
|
|
967
970
|
}
|
|
968
971
|
}
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
972
|
+
/** A property's type, taking the first non-null member of a union. */
|
|
973
|
+
function typeOf(property) {
|
|
974
|
+
const { type } = property;
|
|
975
|
+
if (typeof type === "string") return type;
|
|
976
|
+
return Array.isArray(type) ? type.find((member) => member !== "null") : void 0;
|
|
977
|
+
}
|
|
978
|
+
/** The listing's `unknown` schema as the object shape this reads, when it is one. */
|
|
979
|
+
function asObjectSchema(schema) {
|
|
980
|
+
return isRecord(schema) ? schema : void 0;
|
|
973
981
|
}
|
|
974
982
|
/**
|
|
975
|
-
*
|
|
976
|
-
*
|
|
977
|
-
* A hit is a CANDIDATE and never a decision — see the module doc.
|
|
983
|
+
* A property name as a label — `recordingId` → `Recording id`.
|
|
978
984
|
*
|
|
979
|
-
*
|
|
985
|
+
* A default, not a policy: a schema whose labels matter should carry a
|
|
986
|
+
* `.describe()`, and an author who wants exact control writes the field.
|
|
980
987
|
*/
|
|
981
|
-
function
|
|
982
|
-
const
|
|
983
|
-
return
|
|
988
|
+
function humanize(name) {
|
|
989
|
+
const spaced = name.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim().toLowerCase();
|
|
990
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
984
991
|
}
|
|
992
|
+
//#endregion
|
|
993
|
+
//#region src/components/workflow-progress.tsx
|
|
994
|
+
/** @jsxImportSource react */
|
|
985
995
|
/**
|
|
986
|
-
*
|
|
996
|
+
* What a run has said so far, rendered.
|
|
987
997
|
*
|
|
988
|
-
*
|
|
989
|
-
*
|
|
990
|
-
*
|
|
998
|
+
* The complement of a status line, and the reason both exist: a run is
|
|
999
|
+
* `running` for its whole life, so a one-round job and a ten-round one look
|
|
1000
|
+
* identical while they happen. These lines come from the run itself (`report()`
|
|
1001
|
+
* in a `"use step"` body), which is the only channel a workflow has before it
|
|
1002
|
+
* produces an output.
|
|
991
1003
|
*
|
|
992
|
-
*
|
|
1004
|
+
* Three rules are baked in, and they are why this is a component rather than
|
|
1005
|
+
* three lines each page writes for itself — the two templates that had written
|
|
1006
|
+
* it had written all three, comments included:
|
|
1007
|
+
*
|
|
1008
|
+
* - **It renders nothing until there is something to render.** `supported` is
|
|
1009
|
+
* what keeps this from being an empty box forever on an agent deployed before
|
|
1010
|
+
* progress streams existed: "wrote nothing yet" and "serves no stream" are
|
|
1011
|
+
* indistinguishable from the chunk list alone.
|
|
1012
|
+
* - **The lines are TEXT, not elements.** They are append-only and two rounds
|
|
1013
|
+
* legitimately produce identical text, so there is no stable per-line key to
|
|
1014
|
+
* give React. Joining sidesteps the question instead of suppressing the lint
|
|
1015
|
+
* rule that asks it.
|
|
1016
|
+
* - **They REPLAY.** Chunks are retained with the run, so a reload mid-run —
|
|
1017
|
+
* or opening a finished run tomorrow — shows how it got there rather than an
|
|
1018
|
+
* empty box. That is `useWorkflowProgress`'s doing; this is what makes it
|
|
1019
|
+
* visible.
|
|
1020
|
+
*
|
|
1021
|
+
* @example
|
|
1022
|
+
* ```tsx
|
|
1023
|
+
* import { WorkflowProgress } from "@alexkroman1/aai-ui";
|
|
1024
|
+
*
|
|
1025
|
+
* function RunPanel({ runId }: { runId: string }) {
|
|
1026
|
+
* return <WorkflowProgress runId={runId} />;
|
|
1027
|
+
* }
|
|
1028
|
+
* ```
|
|
1029
|
+
*
|
|
1030
|
+
* @param props - Progress-log props.
|
|
1031
|
+
*
|
|
1032
|
+
* @public
|
|
993
1033
|
*/
|
|
994
|
-
function
|
|
995
|
-
const
|
|
996
|
-
const
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1034
|
+
function WorkflowProgress({ runId, api, className, placeholder, lines }) {
|
|
1035
|
+
const { progress, streaming, supported } = useWorkflowProgress(runId, omitUndefined({ api }));
|
|
1036
|
+
const shown = lines === void 0 ? progress : progress.slice(Math.max(progress.length - lines, 0));
|
|
1037
|
+
if (!supported || shown.length === 0) return placeholder ?? null;
|
|
1038
|
+
return /* @__PURE__ */ jsxs("pre", {
|
|
1039
|
+
className: clsx(className ?? "whitespace-pre-wrap border-l pl-4 text-xs opacity-70"),
|
|
1040
|
+
children: [shown.join("\n"), streaming && "\n…"]
|
|
1041
|
+
});
|
|
1002
1042
|
}
|
|
1043
|
+
//#endregion
|
|
1044
|
+
//#region src/page.tsx
|
|
1045
|
+
/** @jsxImportSource react */
|
|
1003
1046
|
/**
|
|
1004
|
-
*
|
|
1047
|
+
* `page()` — mount a WORKFLOW APP's UI: React, theme, no session.
|
|
1005
1048
|
*
|
|
1006
|
-
* The
|
|
1007
|
-
*
|
|
1008
|
-
*
|
|
1049
|
+
* The twin of `client()` for an agent whose front door is a form rather than a
|
|
1050
|
+
* microphone (`workflowApp()`). It is a separate entry rather than
|
|
1051
|
+
* an option on `client()` because of what `client()` unavoidably does: it
|
|
1052
|
+
* constructs a `SessionCore`, which owns a WebSocket URL provider, an audio
|
|
1053
|
+
* graph, and a microphone request. A flag would have to make all of that
|
|
1054
|
+
* conditional, and every session hook would then have to answer "what does this
|
|
1055
|
+
* mean with no session?" — so the honest split is two mounts. A page that wants
|
|
1056
|
+
* voice uses `client()`; a page that wants neither audio nor a socket uses this.
|
|
1009
1057
|
*
|
|
1010
|
-
*
|
|
1058
|
+
* Authoring is otherwise identical — the file is still `client.tsx`, still
|
|
1059
|
+
* React, still Tailwind, still the same theme tokens — so a workflow app reads
|
|
1060
|
+
* like every other agent. What it reaches for instead of `useSession()` is
|
|
1061
|
+
* `createWorkflowApi()` / `useWorkflowRun()`.
|
|
1011
1062
|
*/
|
|
1012
|
-
function forgetUploadId(scope, file) {
|
|
1013
|
-
const entries = read(scope);
|
|
1014
|
-
const key = fingerprint(file);
|
|
1015
|
-
if (!(key in entries)) return;
|
|
1016
|
-
delete entries[key];
|
|
1017
|
-
write(scope, entries);
|
|
1018
|
-
}
|
|
1019
|
-
//#endregion
|
|
1020
|
-
//#region _upload-session.ts
|
|
1021
|
-
/** Whether this rejection is an abort, in either of the two shapes runtimes throw. */
|
|
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
|
-
}
|
|
1029
1063
|
/**
|
|
1030
|
-
*
|
|
1064
|
+
* Mount a page for an agent whose work happens in workflows.
|
|
1031
1065
|
*
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
1034
|
-
*
|
|
1035
|
-
* false the first time, since a fresh id has nothing to resume and saying
|
|
1036
|
-
* otherwise waives the refusal that makes a caller-chosen id safe.
|
|
1066
|
+
* There is deliberately no session, no microphone, and no socket: the component
|
|
1067
|
+
* talks to the agent over the workflow HTTP API
|
|
1068
|
+
* (`createWorkflowApi`/`useWorkflowRun`), which is durable and outlives the tab.
|
|
1037
1069
|
*
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1070
|
+
* @example
|
|
1071
|
+
* ```tsx
|
|
1072
|
+
* import { createWorkflowApi, page, useWorkflowRun } from "@alexkroman1/aai-ui";
|
|
1073
|
+
* import { useState } from "react";
|
|
1074
|
+
*
|
|
1075
|
+
* // Hoisted: a client built in render is a new object every render.
|
|
1076
|
+
* const api = createWorkflowApi();
|
|
1077
|
+
*
|
|
1078
|
+
* function App() {
|
|
1079
|
+
* const [runId, setRunId] = useState<string>();
|
|
1080
|
+
* const { run } = useWorkflowRun(runId, { api });
|
|
1081
|
+
* return (
|
|
1082
|
+
* <button
|
|
1083
|
+
* type="button"
|
|
1084
|
+
* onClick={() => void api.start("digest", { topic: "ai" }).then(setRunId)}
|
|
1085
|
+
* >
|
|
1086
|
+
* {run ? run.status : "Start"}
|
|
1087
|
+
* </button>
|
|
1088
|
+
* );
|
|
1089
|
+
* }
|
|
1090
|
+
*
|
|
1091
|
+
* page({ name: "Digest", component: App });
|
|
1092
|
+
* ```
|
|
1093
|
+
*
|
|
1094
|
+
* @throws If the target element is not found in the DOM.
|
|
1095
|
+
*
|
|
1096
|
+
* @public
|
|
1041
1097
|
*/
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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
|
-
}
|
|
1098
|
+
function page(config) {
|
|
1099
|
+
const container = resolveContainer(config.target);
|
|
1100
|
+
setPageTitle(config.name);
|
|
1101
|
+
return mountRoot(container, createElement(ThemeProvider, { value: config.theme }, createElement(config.component)));
|
|
1056
1102
|
}
|
|
1103
|
+
//#endregion
|
|
1104
|
+
//#region src/use-download-url.ts
|
|
1057
1105
|
/**
|
|
1058
|
-
*
|
|
1106
|
+
* `useDownloadUrl` — an upload id a run produced, as something `<audio>`,
|
|
1107
|
+
* `<img>` or `<a download>` will accept.
|
|
1059
1108
|
*
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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.
|
|
1109
|
+
* `api.download(id)` resolves a `Blob`, and it has to: the byte route takes the
|
|
1110
|
+
* same bearer every workflow route does, and neither `<audio src>` nor
|
|
1111
|
+
* `<a href>` can send one. So every page that plays back what a run WROTE ends
|
|
1112
|
+
* up at the same four lines — `download` → `createObjectURL` → state — and the
|
|
1113
|
+
* two that are really the point are the two the four lines are wrapped in:
|
|
1113
1114
|
*
|
|
1114
|
-
*
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
1119
|
-
*
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
* The files a submitted field carries, if that is what it carries.
|
|
1115
|
+
* - **`URL.revokeObjectURL` on cleanup.** An object URL pins its blob for the
|
|
1116
|
+
* life of the DOCUMENT. Miss it and every completed run's audio stays resident
|
|
1117
|
+
* until the tab closes, which on a page people run all day is a leak measured
|
|
1118
|
+
* in the size of the files.
|
|
1119
|
+
* - **A `cancelled` flag.** A second run settling while the first download is
|
|
1120
|
+
* still in flight otherwise sets state from the stale one, and the page plays
|
|
1121
|
+
* the previous run's audio under the current run's transcript — a wrong answer
|
|
1122
|
+
* that looks like a right one.
|
|
1123
1123
|
*
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
1126
|
-
*
|
|
1124
|
+
* Two templates had written this hook, identically, doc paragraph included, and
|
|
1125
|
+
* `aai-ui` exported no download helper at all. Both also faked `pending` by
|
|
1126
|
+
* checking `url === undefined && error === undefined`, which reads "idle" and
|
|
1127
|
+
* "downloading" as the same thing — so this reports it.
|
|
1127
1128
|
*/
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
return files.length > 0 && files.length === value.length ? files : [];
|
|
1133
|
-
}
|
|
1129
|
+
/** No id: nothing pending, nothing to show. A shared object so `setState` no-ops. */
|
|
1130
|
+
const IDLE = { pending: false };
|
|
1131
|
+
/** Bytes in flight. Shared for the same reason as {@link IDLE}. */
|
|
1132
|
+
const PENDING = { pending: true };
|
|
1134
1133
|
/**
|
|
1135
|
-
*
|
|
1136
|
-
* being sent.
|
|
1134
|
+
* Read an upload's bytes and hand back a URL a DOM element can use.
|
|
1137
1135
|
*
|
|
1138
|
-
*
|
|
1139
|
-
*
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
1142
|
-
*
|
|
1143
|
-
* about a type, on a form where the user had picked a perfectly good file.
|
|
1136
|
+
* @param uploadId - The id a completed run reported, or `undefined` before one
|
|
1137
|
+
* exists — which is what a page passes straight through while it waits, and
|
|
1138
|
+
* reports as idle rather than pending.
|
|
1139
|
+
* @param opts - See {@link UseDownloadUrlOptions}.
|
|
1140
|
+
* @returns See {@link UseDownloadUrlResult}.
|
|
1144
1141
|
*
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
1147
|
-
*
|
|
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.
|
|
1142
|
+
* @example
|
|
1143
|
+
* ```tsx no-check
|
|
1144
|
+
* import { useDownloadUrl, useWorkflowSubmit } from "@alexkroman1/aai-ui";
|
|
1145
|
+
* import type { spokenSummary } from "./agent.ts";
|
|
1157
1146
|
*
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1160
|
-
*
|
|
1161
|
-
*
|
|
1147
|
+
* function Playback() {
|
|
1148
|
+
* const { run } = useWorkflowSubmit<typeof spokenSummary>("spokenSummary");
|
|
1149
|
+
* const output = run?.status === "completed" ? run.output : undefined;
|
|
1150
|
+
* const audio = useDownloadUrl(output?.audio);
|
|
1151
|
+
* if (audio.pending) return <p>Fetching audio…</p>;
|
|
1152
|
+
* if (audio.error !== undefined) return <p role="alert">{audio.error}</p>;
|
|
1153
|
+
* return audio.url === undefined ? null : (
|
|
1154
|
+
* <a href={audio.url} download="summary.mp3">
|
|
1155
|
+
* Download
|
|
1156
|
+
* </a>
|
|
1157
|
+
* );
|
|
1158
|
+
* }
|
|
1159
|
+
* ```
|
|
1162
1160
|
*
|
|
1163
|
-
*
|
|
1164
|
-
* one file rather than walking an input and shares only the gate underneath both
|
|
1165
|
-
* (`_upload-session.ts`).
|
|
1161
|
+
* @public
|
|
1166
1162
|
*/
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1163
|
+
function useDownloadUrl(uploadId, opts = {}) {
|
|
1164
|
+
const [state, setState] = useState(IDLE);
|
|
1165
|
+
const getClient = useWorkflowApiRef(opts.api);
|
|
1166
|
+
useEffect(() => {
|
|
1167
|
+
if (uploadId === void 0) {
|
|
1168
|
+
setState(IDLE);
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
let cancelled = false;
|
|
1172
|
+
let objectUrl;
|
|
1173
|
+
setState(PENDING);
|
|
1174
|
+
getClient().download(uploadId).then((blob) => {
|
|
1175
|
+
if (cancelled) return;
|
|
1176
|
+
objectUrl = URL.createObjectURL(blob);
|
|
1177
|
+
setState({
|
|
1178
|
+
url: objectUrl,
|
|
1179
|
+
pending: false
|
|
1180
|
+
});
|
|
1181
|
+
}).catch((err) => {
|
|
1182
|
+
if (!cancelled) setState({
|
|
1183
|
+
error: errorMessage(err),
|
|
1184
|
+
pending: false
|
|
1185
|
+
});
|
|
1186
|
+
});
|
|
1187
|
+
return () => {
|
|
1188
|
+
cancelled = true;
|
|
1189
|
+
if (objectUrl !== void 0) URL.revokeObjectURL(objectUrl);
|
|
1190
|
+
};
|
|
1191
|
+
}, [uploadId, getClient]);
|
|
1192
|
+
return state;
|
|
1176
1193
|
}
|
|
1194
|
+
//#endregion
|
|
1195
|
+
//#region src/use-run-key.ts
|
|
1177
1196
|
/**
|
|
1178
|
-
* The
|
|
1179
|
-
* fresh one.
|
|
1197
|
+
* The handle a page keeps on the runs it started, across a reload.
|
|
1180
1198
|
*
|
|
1181
|
-
*
|
|
1182
|
-
*
|
|
1183
|
-
*
|
|
1184
|
-
*
|
|
1199
|
+
* `useWorkflowSubmit` is what makes a run survivable — the run id is that
|
|
1200
|
+
* hook's own state, so a refresh loses it while the run carries on — and the
|
|
1201
|
+
* `key` is what it looks the run up BY, because it is a lookup CAPABILITY:
|
|
1202
|
+
* there is no per-user filtering behind `find`, so the key IS the scoping
|
|
1203
|
+
* mechanism. Choosing one is easy to get wrong in three separate ways, and six
|
|
1204
|
+
* shipped templates had each written the same twenty lines to get it right.
|
|
1205
|
+
* This is those lines.
|
|
1206
|
+
*
|
|
1207
|
+
* **`useWorkflowSubmit` now mints one for itself** ({@link useDefaultRunKey}),
|
|
1208
|
+
* so a page resumes its own run across a reload with nothing written at the
|
|
1209
|
+
* call site — six of six page templates passed `useRunKey()` and
|
|
1210
|
+
* `recover: true`, which is a default in the wrong place. The hook stays
|
|
1211
|
+
* PUBLIC for the page that wants to choose: an app with accounts passes the
|
|
1212
|
+
* ACCOUNT's own id instead, and a run then follows the person to a new device,
|
|
1213
|
+
* which is a promise only a login can keep; a page whose run outlives the tab
|
|
1214
|
+
* passes `useRunKey({ storage: "local" })`.
|
|
1185
1215
|
*
|
|
1186
|
-
* Three
|
|
1216
|
+
* ## Three properties, and the rejected alternatives are why each one matters
|
|
1187
1217
|
*
|
|
1188
|
-
* - **
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
1191
|
-
*
|
|
1192
|
-
*
|
|
1193
|
-
*
|
|
1194
|
-
*
|
|
1195
|
-
*
|
|
1196
|
-
*
|
|
1197
|
-
* dropped and the file gets a fresh id.
|
|
1198
|
-
*/
|
|
1199
|
-
async function claimId(api, session, file) {
|
|
1200
|
-
const remembered = recallUploadId(session.scope, file);
|
|
1201
|
-
if (remembered === void 0) return {
|
|
1202
|
-
id: randomUploadId(),
|
|
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
|
-
}
|
|
1223
|
-
/**
|
|
1224
|
-
* Replace every `File` in a submitted form with the id of a stored upload,
|
|
1225
|
-
* reporting how far each one has got.
|
|
1218
|
+
* - **Opaque** — `crypto.randomUUID()`, never derived from what was submitted.
|
|
1219
|
+
* A key derived from the input collides the moment two people submit the same
|
|
1220
|
+
* thing, and they then recover each other's runs; it also carries what they
|
|
1221
|
+
* typed into a lookup token the platform deliberately stopped logging.
|
|
1222
|
+
* - **Short.** A `randomUUID` is 36 characters, well inside the 256 that
|
|
1223
|
+
* `POST /workflows/runs` allows a key.
|
|
1224
|
+
* - **Minted once per load and written back for the next one**, which is the
|
|
1225
|
+
* whole mechanism: the load that presses the button records the key with the
|
|
1226
|
+
* run, and the load after it finds the run by producing the same key.
|
|
1226
1227
|
*
|
|
1227
|
-
*
|
|
1228
|
-
*
|
|
1229
|
-
*
|
|
1230
|
-
*
|
|
1228
|
+
* Storage rather than the page's own URL, for all of them. A `?key=` parameter
|
|
1229
|
+
* survives more (a new tab, a bookmark, a shared link) and that is the problem:
|
|
1230
|
+
* a URL is pasted into chats, copied into referrers and kept in history, and
|
|
1231
|
+
* what a leaked one buys is somebody else's work — reading it, and `cancel()` on
|
|
1232
|
+
* it. An app with accounts should pass the ACCOUNT's own id here instead, and
|
|
1233
|
+
* then a run follows the person to a new device, which is a promise only a login
|
|
1234
|
+
* can keep.
|
|
1231
1235
|
*
|
|
1232
|
-
*
|
|
1233
|
-
* so this is invisible to every form that has none — including one whose values
|
|
1234
|
-
* are not an object at all, which `submit` accepts.
|
|
1236
|
+
* ## The storage is the caller's decision, and it is not a detail
|
|
1235
1237
|
*
|
|
1236
|
-
*
|
|
1238
|
+
* `"session"` (the default) dies with the tab, which covers exactly the
|
|
1239
|
+
* interruption most pages have — a reload, a same-tab navigation, a crashed tab
|
|
1240
|
+
* — and is the same lifetime as this package's other two stores, the session
|
|
1241
|
+
* resume id (`session-resume-store.ts`) and the upload recall
|
|
1242
|
+
* (`_upload-recall.ts`), so both halves of a reload make the same promise.
|
|
1237
1243
|
*
|
|
1238
|
-
*
|
|
1239
|
-
*
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1242
|
-
*
|
|
1243
|
-
*
|
|
1244
|
+
* `"local"` is for a run that outlives all of that BY DESIGN — one that sleeps
|
|
1245
|
+
* between digests and may live a month, where closing the browser on Tuesday and
|
|
1246
|
+
* coming back on Friday to press Stop is the ordinary case rather than an edge
|
|
1247
|
+
* one, and a tab-scoped key would answer that with an empty form beside a run
|
|
1248
|
+
* still posting somewhere. It is as far as a key can go without a login, and no
|
|
1249
|
+
* further. `podcast-digest` is that template, and the reason this hook is still
|
|
1250
|
+
* called by name anywhere; the other five take the tab-scoped default the
|
|
1251
|
+
* submit hook mints for them.
|
|
1244
1252
|
*
|
|
1245
|
-
*
|
|
1246
|
-
*
|
|
1247
|
-
*
|
|
1253
|
+
* ## Anything ELSE a page stores back must be VALIDATED on read
|
|
1254
|
+
*
|
|
1255
|
+
* This key needs no validation, and it is worth saying why, because it is the
|
|
1256
|
+
* exception: any string is a legal key, so a value from storage can only fail to
|
|
1257
|
+
* match a run. A page that remembers something more — which MODE submitted, say
|
|
1258
|
+
* — is remembering a value it will turn into a name, and storage hands back a
|
|
1259
|
+
* string some earlier version of that page wrote: a renamed mode, a hand-edited
|
|
1260
|
+
* value, a slot another app on the origin happens to share. Unchecked, that
|
|
1261
|
+
* starts a run called `undefined` and answers a 400 nobody typed. Check it
|
|
1262
|
+
* against the page's own list on the way out (`recalledMode` in
|
|
1263
|
+
* `transcription-workflow/recover.ts` is the worked example) — the recall is the
|
|
1264
|
+
* page's, the validation is not optional.
|
|
1265
|
+
*
|
|
1266
|
+
* ## The slot is keyed by the page's own URL
|
|
1267
|
+
*
|
|
1268
|
+
* Every deployed agent is served from one origin at `/:slug/`, so a fixed name
|
|
1269
|
+
* would have two agents scaffolded from the same template recover each other's
|
|
1270
|
+
* runs. The key is the page's own directory — resolved through `"./"`, which
|
|
1271
|
+
* drops the query and the hash, since a reload carrying `?foo` or `#bar` has to
|
|
1272
|
+
* find the same key. Same call `session-resume-store.ts` makes, for the same
|
|
1273
|
+
* reason.
|
|
1274
|
+
*
|
|
1275
|
+
* One key per PAGE is right even for a page driving several workflows: `find` is
|
|
1276
|
+
* scoped by workflow as well as by key, so three hooks sharing one key recover
|
|
1277
|
+
* three separate runs. `transcription-workflow` is that page.
|
|
1278
|
+
*
|
|
1279
|
+
* Every access is guarded. Storage THROWS outright in some contexts (Safari
|
|
1280
|
+
* private mode, an iframe blocked by policy) and is ABSENT in others (any
|
|
1281
|
+
* server-side render), and a page that cannot remember its key must degrade to
|
|
1282
|
+
* the behaviour it would have had anyway — one run per load — rather than
|
|
1283
|
+
* failing to render.
|
|
1248
1284
|
*/
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
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);
|
|
1272
|
-
}
|
|
1273
|
-
if (claimed.complete) {
|
|
1274
|
-
report({
|
|
1275
|
-
...position,
|
|
1276
|
-
loaded: file.size,
|
|
1277
|
-
total: file.size,
|
|
1278
|
-
fraction: 1,
|
|
1279
|
-
paused: false
|
|
1280
|
-
});
|
|
1281
|
-
session.stored.set(file, id);
|
|
1282
|
-
return id;
|
|
1283
|
-
}
|
|
1284
|
-
await sendThroughGate(session.gate, async (resume) => {
|
|
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
|
-
})
|
|
1297
|
-
});
|
|
1298
|
-
});
|
|
1299
|
-
session.stored.set(file, id);
|
|
1300
|
-
return id;
|
|
1301
|
-
};
|
|
1302
|
-
const out = {};
|
|
1303
|
-
for (const [name, value] of entries) {
|
|
1304
|
-
if (value instanceof File) {
|
|
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;
|
|
1285
|
+
/** Where a run key lives, namespaced like this package's two other stores. */
|
|
1286
|
+
const PREFIX$1 = "aai:run-key:";
|
|
1287
|
+
/** This page's own slot — see "The slot is keyed by the page's own URL". */
|
|
1288
|
+
function slotFor() {
|
|
1289
|
+
const href = globalThis.location?.href;
|
|
1290
|
+
if (href === void 0) return PREFIX$1;
|
|
1291
|
+
try {
|
|
1292
|
+
return `${PREFIX$1}${new URL("./", href).href}`;
|
|
1293
|
+
} catch {
|
|
1294
|
+
return `${PREFIX$1}${href}`;
|
|
1316
1295
|
}
|
|
1317
|
-
return out;
|
|
1318
1296
|
}
|
|
1319
|
-
//#endregion
|
|
1320
|
-
//#region _upload-pause.ts
|
|
1321
1297
|
/**
|
|
1322
|
-
*
|
|
1323
|
-
* submission is currently holding.
|
|
1298
|
+
* Read the key this page already has, or mint and remember one.
|
|
1324
1299
|
*
|
|
1325
|
-
*
|
|
1326
|
-
*
|
|
1327
|
-
*
|
|
1328
|
-
* everything else about that status — which file, how far, of how many — is
|
|
1329
|
-
* still true. Two copies of that rule are two copies that can stop agreeing
|
|
1330
|
-
* about what a paused bar says.
|
|
1300
|
+
* Not exported: a page that wants a key wants it for the life of a component,
|
|
1301
|
+
* which is what the hook is. Calling this per render would mint a fresh key and
|
|
1302
|
+
* hand `recover` one nothing was ever started under.
|
|
1331
1303
|
*/
|
|
1304
|
+
function mintRunKey(storage) {
|
|
1305
|
+
try {
|
|
1306
|
+
const store = storage === "local" ? globalThis.localStorage : globalThis.sessionStorage;
|
|
1307
|
+
const slot = slotFor();
|
|
1308
|
+
const stored = store?.getItem(slot);
|
|
1309
|
+
if (stored !== null && stored !== void 0) return stored;
|
|
1310
|
+
const minted = crypto.randomUUID();
|
|
1311
|
+
store?.setItem(slot, minted);
|
|
1312
|
+
return minted;
|
|
1313
|
+
} catch {
|
|
1314
|
+
return crypto.randomUUID();
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1332
1317
|
/**
|
|
1333
|
-
*
|
|
1318
|
+
* The key `useWorkflowSubmit` uses when the page named none.
|
|
1334
1319
|
*
|
|
1335
|
-
*
|
|
1336
|
-
*
|
|
1337
|
-
*
|
|
1320
|
+
* Two things it does that a plain `useRunKey()` at the call site cannot, and
|
|
1321
|
+
* both are about a page that DID name one:
|
|
1322
|
+
*
|
|
1323
|
+
* - **It mints nothing when the caller has a key.** Minting writes to storage,
|
|
1324
|
+
* so an unconditional `useRunKey()` inside the hook would leave a slot behind
|
|
1325
|
+
* on every page that passes an account id and never reads it back.
|
|
1326
|
+
* - **It stays reactive to the caller's key.** A key that arrives late — an
|
|
1327
|
+
* account id resolved after a login — must reach the lookup, which re-asks on
|
|
1328
|
+
* a changed key by design; freezing it into `useState` would pin the page to
|
|
1329
|
+
* whatever it held on its first render.
|
|
1330
|
+
*
|
|
1331
|
+
* The minted half is still frozen for the component's life, which is what
|
|
1332
|
+
* `useRunKey` freezes it for: a fresh key per render would record every run
|
|
1333
|
+
* under a name the next load cannot produce.
|
|
1334
|
+
*
|
|
1335
|
+
* @param explicit - The caller's own key, or undefined for a page with none.
|
|
1336
|
+
* @returns The key to record runs under and look them up by.
|
|
1338
1337
|
*
|
|
1339
1338
|
* @internal
|
|
1340
1339
|
*/
|
|
1341
|
-
function
|
|
1342
|
-
const
|
|
1343
|
-
|
|
1344
|
-
...current,
|
|
1345
|
-
paused
|
|
1346
|
-
} : current);
|
|
1347
|
-
}, [setUpload]);
|
|
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
|
-
};
|
|
1340
|
+
function useDefaultRunKey(explicit) {
|
|
1341
|
+
const [minted] = useState(() => explicit === void 0 ? mintRunKey("session") : crypto.randomUUID());
|
|
1342
|
+
return explicit ?? minted;
|
|
1358
1343
|
}
|
|
1359
|
-
//#endregion
|
|
1360
|
-
//#region use-workflow-form.ts
|
|
1361
1344
|
/**
|
|
1362
|
-
*
|
|
1345
|
+
* A lookup key for `useWorkflowSubmit({ key })`, stable across reloads.
|
|
1363
1346
|
*
|
|
1364
|
-
*
|
|
1365
|
-
*
|
|
1366
|
-
*
|
|
1367
|
-
*
|
|
1368
|
-
*
|
|
1347
|
+
* @param options - See the module doc for the whole argument. The storage kind
|
|
1348
|
+
* is read once, when the key is minted: a value that changed afterwards would
|
|
1349
|
+
* be asking to move a key that has already been recorded with a run.
|
|
1350
|
+
* @returns The key to record runs under and to look them up by — the same one
|
|
1351
|
+
* for the life of the component, and for the next load in the same tab (or the
|
|
1352
|
+
* same browser, under `"local"`).
|
|
1353
|
+
*/
|
|
1354
|
+
function useRunKey(options = {}) {
|
|
1355
|
+
const { storage = "session" } = options;
|
|
1356
|
+
const [key] = useState(() => mintRunKey(storage));
|
|
1357
|
+
return key;
|
|
1358
|
+
}
|
|
1359
|
+
//#endregion
|
|
1360
|
+
//#region src/_recover-run.ts
|
|
1361
|
+
/**
|
|
1362
|
+
* Finding a run again when the page has lost its id.
|
|
1369
1363
|
*
|
|
1370
|
-
*
|
|
1364
|
+
* A run is durable and a page is not — which `useWorkflowRun`'s doc says, and
|
|
1365
|
+
* which was only half true of the hooks above it: the run id lived in plain
|
|
1366
|
+
* `useState`, so a refresh (or a same-tab navigation, or a crashed tab) left a
|
|
1367
|
+
* live run with nothing anywhere able to name it. The run really did continue;
|
|
1368
|
+
* the person really could not get back to it.
|
|
1371
1369
|
*
|
|
1372
|
-
*
|
|
1373
|
-
*
|
|
1374
|
-
*
|
|
1375
|
-
*
|
|
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.
|
|
1370
|
+
* `StartOptions.key` is the handle that survives that, and it always was — a
|
|
1371
|
+
* caller's own name for a run, indexed by the agent, read back with
|
|
1372
|
+
* `find(workflow, key)`. What was missing is the two lines that ASK. This is
|
|
1373
|
+
* them, plus the four decisions they turn out to carry.
|
|
1379
1374
|
*
|
|
1380
|
-
*
|
|
1381
|
-
* them. It adds no transport of its own and holds no run state of its own; the
|
|
1382
|
-
* watching (stream first, poll as its fallback, terminal stops) is entirely
|
|
1383
|
-
* `useWorkflowRun`'s, and `run` here IS its run.
|
|
1375
|
+
* ## It is a MOUNT-time act, not "whenever there is no run"
|
|
1384
1376
|
*
|
|
1385
|
-
*
|
|
1377
|
+
* The tempting spelling is "if we hold no run id, look one up", and it breaks
|
|
1378
|
+
* `reset()`: a form put back to its initial state holds no run id, so the next
|
|
1379
|
+
* pass would re-adopt the very run the person had just dismissed — a Clear
|
|
1380
|
+
* button that clears nothing. So the lookup runs once per mount (and again only
|
|
1381
|
+
* if the KEY changes, which is a different person's run), and every later
|
|
1382
|
+
* absence of a run id is taken at face value.
|
|
1386
1383
|
*
|
|
1387
|
-
*
|
|
1388
|
-
* default for a page: it holds a socket open for up to a minute, answers nothing
|
|
1389
|
-
* until it settles, and a page has `useWorkflowRun` — which survives a reload,
|
|
1390
|
-
* shows progress, and costs one stream. The synchronous call is for callers with
|
|
1391
|
-
* nowhere to put a watch (a script, a cron, a form POST from a server). Pass
|
|
1392
|
-
* `wait` here when the page really does want one request, and the run is
|
|
1393
|
-
* followed from the same id either way.
|
|
1394
|
-
*/
|
|
1395
|
-
/**
|
|
1396
|
-
* Read the agent's declared workflows.
|
|
1384
|
+
* ## The lookup NEVER wins a race against a submit
|
|
1397
1385
|
*
|
|
1398
|
-
*
|
|
1399
|
-
*
|
|
1400
|
-
*
|
|
1386
|
+
* A person who reloads and immediately submits has started the run they want,
|
|
1387
|
+
* and an answer that was already in flight names an older one. The caller
|
|
1388
|
+
* therefore adopts through `current ?? found`: the recovered id fills an empty
|
|
1389
|
+
* slot and never replaces a full one.
|
|
1401
1390
|
*
|
|
1402
|
-
*
|
|
1403
|
-
* empty list — which renders as a form with no fields and reads as "this agent
|
|
1404
|
-
* declares no workflows" about an agent that was merely unreachable.
|
|
1391
|
+
* ## A failed lookup is REPORTED
|
|
1405
1392
|
*
|
|
1406
|
-
*
|
|
1407
|
-
*
|
|
1408
|
-
*
|
|
1393
|
+
* The alternative is a page that quietly shows an empty form to somebody whose
|
|
1394
|
+
* run is live, who then starts a second one — the duplicated work the key
|
|
1395
|
+
* exists to prevent, and on a workflow app that is real money. A person who has
|
|
1396
|
+
* never run anything pays a banner they can ignore. Same trade as
|
|
1397
|
+
* `useWorkflows`, for the same reason: an empty answer here is a confident
|
|
1398
|
+
* false statement.
|
|
1409
1399
|
*
|
|
1410
|
-
*
|
|
1411
|
-
* // for ONE workflow wants `<WorkflowFields workflow="name" />` instead,
|
|
1412
|
-
* // which does this lookup itself.
|
|
1413
|
-
* function WorkflowPicker({ onPick }: { onPick: (name: string) => void }) {
|
|
1414
|
-
* const { workflows, loading, error } = useWorkflows();
|
|
1415
|
-
* if (loading) return <p>Loading…</p>;
|
|
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
|
-
* ```
|
|
1400
|
+
* ## It is ON by default, and it used to be opt-in
|
|
1430
1401
|
*
|
|
1431
|
-
*
|
|
1432
|
-
*
|
|
1433
|
-
*
|
|
1402
|
+
* The argument for opt-in was that a `key` on its own means only "record this
|
|
1403
|
+
* with the run" — which is what a voice agent's `ctx.workflows.start({ key })`
|
|
1404
|
+
* means, there being no page to put a run back on. A FORM is the other case: it
|
|
1405
|
+
* is the page, and losing the run is the thing it cannot recover from. Six of
|
|
1406
|
+
* six page templates wrote `useRunKey()` and `recover: true` together, which is
|
|
1407
|
+
* the same shape `session-resume-store.ts` names on the voice side — a default
|
|
1408
|
+
* in the wrong place — so `useWorkflowSubmit` now mints the key and asks.
|
|
1434
1409
|
*
|
|
1435
|
-
*
|
|
1410
|
+
* `enabled` remains, because `recover: false` remains: a page whose form must
|
|
1411
|
+
* always open empty says so, and then nothing here runs.
|
|
1436
1412
|
*/
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1413
|
+
/**
|
|
1414
|
+
* Look up the newest run for a key, once, as the component mounts.
|
|
1415
|
+
*
|
|
1416
|
+
* @param opts - See {@link RecoverRunOptions}.
|
|
1417
|
+
* @returns Whether the lookup is still out. A caller folds it into its own
|
|
1418
|
+
* `pending`, because a form offering Submit while a live run is arriving is a
|
|
1419
|
+
* form inviting a second one.
|
|
1420
|
+
*
|
|
1421
|
+
* @internal
|
|
1422
|
+
*/
|
|
1423
|
+
function useRecoveredRun(opts) {
|
|
1424
|
+
const { workflow, key, enabled, getClient } = opts;
|
|
1425
|
+
const [recovering, setRecovering] = useState(enabled && key !== void 0);
|
|
1426
|
+
const handlers = useRef(opts);
|
|
1427
|
+
handlers.current = opts;
|
|
1445
1428
|
useEffect(() => {
|
|
1446
|
-
if (
|
|
1429
|
+
if (!enabled || key === void 0) return;
|
|
1447
1430
|
let cancelled = false;
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1431
|
+
setRecovering(true);
|
|
1432
|
+
getClient().find(workflow, key, { limit: 1 }).then((found) => {
|
|
1433
|
+
if (cancelled) return;
|
|
1434
|
+
const newest = found[0];
|
|
1435
|
+
if (newest !== void 0) handlers.current.onFound(newest.runId);
|
|
1436
|
+
setRecovering(false);
|
|
1454
1437
|
}).catch((err) => {
|
|
1455
1438
|
if (cancelled) return;
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
loading: false,
|
|
1459
|
-
error: errorMessage(err)
|
|
1460
|
-
});
|
|
1439
|
+
handlers.current.onError(errorMessage(err));
|
|
1440
|
+
setRecovering(false);
|
|
1461
1441
|
});
|
|
1462
1442
|
return () => {
|
|
1463
1443
|
cancelled = true;
|
|
1464
1444
|
};
|
|
1465
|
-
}, [
|
|
1466
|
-
|
|
1445
|
+
}, [
|
|
1446
|
+
enabled,
|
|
1447
|
+
key,
|
|
1448
|
+
workflow,
|
|
1449
|
+
getClient
|
|
1450
|
+
]);
|
|
1451
|
+
return recovering;
|
|
1467
1452
|
}
|
|
1453
|
+
//#endregion
|
|
1454
|
+
//#region src/_run-controls.ts
|
|
1468
1455
|
/**
|
|
1469
|
-
*
|
|
1470
|
-
*
|
|
1471
|
-
* @typeParam D - The workflow DEFINITION, which types both halves of the
|
|
1472
|
-
* submission: `submit(input)` takes what the workflow's schema parses to, and
|
|
1473
|
-
* `run.status === "completed"` narrows to a typed `run.output`.
|
|
1456
|
+
* The two things a page does TO a run it started, bound to the run it has.
|
|
1474
1457
|
*
|
|
1475
|
-
*
|
|
1476
|
-
*
|
|
1477
|
-
*
|
|
1478
|
-
*
|
|
1479
|
-
*
|
|
1480
|
-
* Passing an output type where a def belongs is now a compile error rather
|
|
1481
|
-
* than a silent loss of typing, which is the point.
|
|
1458
|
+
* `useWorkflowSubmit` and `useWorkflowStream` both hold a run id and neither
|
|
1459
|
+
* handed it back, so a page that wanted "send it now" or "stop" had to hold an
|
|
1460
|
+
* `api` of its own purely to write `api.wake(runId)` — which is the whole reason
|
|
1461
|
+
* the two raw-primitive template pages keep a client at module scope. That is a
|
|
1462
|
+
* page carrying the transport to make up for a hook withholding its own state.
|
|
1482
1463
|
*
|
|
1483
|
-
*
|
|
1484
|
-
*
|
|
1485
|
-
*
|
|
1486
|
-
*
|
|
1464
|
+
* Both calls answer rather than fail when there is nothing to act on — `0`
|
|
1465
|
+
* sleeps ended, `false` this call did not end it — which is the SDK's own
|
|
1466
|
+
* contract for them (two tabs pressing Stop is ordinary), and it is what lets
|
|
1467
|
+
* the no-run case be the same answer rather than a special one a caller has to
|
|
1468
|
+
* branch on.
|
|
1469
|
+
*/
|
|
1470
|
+
/**
|
|
1471
|
+
* Bind `wake` and `cancel` to whatever run the hook is currently following.
|
|
1487
1472
|
*
|
|
1488
|
-
*
|
|
1489
|
-
*
|
|
1490
|
-
*
|
|
1491
|
-
* <Form onSubmit={(values) => submit(values)} error={error}>
|
|
1492
|
-
* <TextField name="url" label="Link" type="url" required />
|
|
1493
|
-
* <SubmitButton pending={pending}>Digest</SubmitButton>
|
|
1494
|
-
* {run?.status === "completed" && <p>{run.output.title}</p>}
|
|
1495
|
-
* </Form>
|
|
1496
|
-
* );
|
|
1497
|
-
* }
|
|
1498
|
-
* ```
|
|
1473
|
+
* @param runId - The live run, or `undefined` before one exists.
|
|
1474
|
+
* @param getClient - The stable getter from `useWorkflowApiRef`.
|
|
1475
|
+
* @returns Two callbacks, stable while `runId` is.
|
|
1499
1476
|
*
|
|
1500
|
-
* @
|
|
1477
|
+
* @internal
|
|
1501
1478
|
*/
|
|
1502
|
-
function
|
|
1503
|
-
const { api, key, recover = false, wait, intervalMs, parallel } = opts;
|
|
1504
|
-
const [runId, setRunId] = useState(void 0);
|
|
1505
|
-
const [starting, setStarting] = useState(false);
|
|
1506
|
-
const [startError, setStartError] = useState(void 0);
|
|
1507
|
-
const [upload, setUpload] = useState(void 0);
|
|
1508
|
-
const session = useRef(void 0);
|
|
1509
|
-
const getClient = useWorkflowApiRef(api);
|
|
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();
|
|
1527
|
-
setStarting(true);
|
|
1528
|
-
setStartError(void 0);
|
|
1529
|
-
setRunId(void 0);
|
|
1530
|
-
session.current?.gate.cancel();
|
|
1531
|
-
const current = createUploadSession(workflow);
|
|
1532
|
-
session.current = current;
|
|
1533
|
-
try {
|
|
1534
|
-
const options = omitUndefined({ key });
|
|
1535
|
-
const started = await uploadFiles(client, input, setUpload, parallel, current);
|
|
1536
|
-
setRunId(wait === void 0 ? await client.start(workflow, started, options) : (await client.startAndWait(workflow, started, {
|
|
1537
|
-
...options,
|
|
1538
|
-
wait
|
|
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
|
-
]);
|
|
1556
|
-
const reset = useCallback(() => {
|
|
1557
|
-
session.current?.gate.cancel();
|
|
1558
|
-
session.current = void 0;
|
|
1559
|
-
setRunId(void 0);
|
|
1560
|
-
setStartError(void 0);
|
|
1561
|
-
setUpload(void 0);
|
|
1562
|
-
}, []);
|
|
1563
|
-
const { pauseUpload, resumeUpload } = useUploadPause(useCallback(() => session.current?.gate, []), setUpload);
|
|
1479
|
+
function useRunControls(runId, getClient) {
|
|
1564
1480
|
return {
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
cancel
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
pending: recovering || starting || tracked.polling,
|
|
1574
|
-
upload,
|
|
1575
|
-
error: startError ?? tracked.error
|
|
1481
|
+
wake: useCallback(async () => {
|
|
1482
|
+
if (runId === void 0) return 0;
|
|
1483
|
+
return await getClient().wake(runId);
|
|
1484
|
+
}, [runId, getClient]),
|
|
1485
|
+
cancel: useCallback(async () => {
|
|
1486
|
+
if (runId === void 0) return false;
|
|
1487
|
+
return await getClient().cancel(runId);
|
|
1488
|
+
}, [runId, getClient])
|
|
1576
1489
|
};
|
|
1577
1490
|
}
|
|
1578
1491
|
//#endregion
|
|
1579
|
-
//#region
|
|
1580
|
-
/** @jsxImportSource react */
|
|
1492
|
+
//#region src/_upload-recall.ts
|
|
1581
1493
|
/**
|
|
1582
|
-
*
|
|
1583
|
-
*
|
|
1584
|
-
* `GET workflows` reports each workflow's `inputSchema` as JSON Schema — the
|
|
1585
|
-
* zod schema an author wrote in `agent.ts`, converted at listing time precisely
|
|
1586
|
-
* so a browser can read it. This is what reads it: one `<WorkflowFields>` and a
|
|
1587
|
-
* workflow's form matches its schema by construction, so adding a field to the
|
|
1588
|
-
* schema adds it to the page and nothing can drift.
|
|
1589
|
-
*
|
|
1590
|
-
* ## It covers SCALARS, and says so rather than guessing
|
|
1494
|
+
* Where an upload's ID survives a page RELOAD.
|
|
1591
1495
|
*
|
|
1592
|
-
* A
|
|
1593
|
-
*
|
|
1594
|
-
* a
|
|
1595
|
-
*
|
|
1596
|
-
*
|
|
1597
|
-
*
|
|
1598
|
-
*
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
*
|
|
1496
|
+
* A streamed upload is resumable because its id outlives the attempt that began
|
|
1497
|
+
* it — `_upload-files.ts` says so, and `_upload-session.ts` turns that into a
|
|
1498
|
+
* pause a person can press. Both of them hold the id in MEMORY: the walk's
|
|
1499
|
+
* `UploadSession` lives in a `useRef`, so a reload was the one interruption the
|
|
1500
|
+
* mechanism could not survive. Everything else was already in place — the windows
|
|
1501
|
+
* were still in the store, the agent could still name them
|
|
1502
|
+
* (`UploadInfo.ranges`), and the id was minted in the browser — and the browser
|
|
1503
|
+
* had thrown away the only name for them. So a person who refreshed at 90% of a
|
|
1504
|
+
* 200 MB recording sent the whole file again, which is the one interruption they
|
|
1505
|
+
* are most likely to cause on purpose.
|
|
1602
1506
|
*
|
|
1603
|
-
*
|
|
1604
|
-
*
|
|
1605
|
-
*
|
|
1606
|
-
* and the alternative is three lines (`useWorkflows()`, a `.find()` by name, and
|
|
1607
|
-
* folding that lookup's error into the form's) whose only product is this
|
|
1608
|
-
* component's argument.
|
|
1507
|
+
* This is that name, written down. It is what tus-js-client's `urlStorage` and
|
|
1508
|
+
* Uppy's Golden Retriever sell, in the shape `session-resume-store.ts` already
|
|
1509
|
+
* uses for a session id.
|
|
1609
1510
|
*
|
|
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.
|
|
1511
|
+
* ## A FINGERPRINT, because a `File` has no name a page can address
|
|
1614
1512
|
*
|
|
1615
|
-
*
|
|
1616
|
-
*
|
|
1617
|
-
*
|
|
1618
|
-
*
|
|
1619
|
-
*
|
|
1513
|
+
* A file from a picker carries no path and no handle, so the key is what
|
|
1514
|
+
* tus-js-client fingerprints on: size, last-modified, type and name. Two
|
|
1515
|
+
* different files agreeing on all four is the case this cannot tell apart — and
|
|
1516
|
+
* the reason NOTHING here decides to resume. `_upload-files.ts` asks the agent
|
|
1517
|
+
* what the id actually holds before sending a byte to it, so a wrong hit costs
|
|
1518
|
+
* one `GET` and a fresh id rather than a corrupted upload.
|
|
1620
1519
|
*
|
|
1621
|
-
*
|
|
1622
|
-
* const { submitForm, pending, error } = useWorkflowSubmit<typeof transcribe>("transcribe");
|
|
1623
|
-
* return (
|
|
1624
|
-
* <Form onSubmit={submitForm} error={error}>
|
|
1625
|
-
* <WorkflowFields workflow="transcribe" />
|
|
1626
|
-
* <SubmitButton pending={pending}>Transcribe</SubmitButton>
|
|
1627
|
-
* </Form>
|
|
1628
|
-
* );
|
|
1629
|
-
* }
|
|
1630
|
-
* ```
|
|
1520
|
+
* ## `sessionStorage`, deliberately
|
|
1631
1521
|
*
|
|
1632
|
-
*
|
|
1522
|
+
* The same call `session-resume-store.ts` makes, for a reason that happens to be
|
|
1523
|
+
* stronger here: a reload and a same-tab navigation are exactly what this is for,
|
|
1524
|
+
* and an id from yesterday names an upload the agent's sweep has very likely
|
|
1525
|
+
* already collected. A tab is also the boundary the walk itself has — two tabs
|
|
1526
|
+
* uploading the same recording are two submissions.
|
|
1633
1527
|
*
|
|
1634
|
-
*
|
|
1528
|
+
* Every access is guarded. Storage throws outright in Safari private mode and
|
|
1529
|
+
* under a blocking policy, and an upload that cannot be REMEMBERED must degrade
|
|
1530
|
+
* to the upload we would have done anyway rather than failing to start.
|
|
1635
1531
|
*/
|
|
1636
|
-
|
|
1637
|
-
const { workflows, loading } = useWorkflows(typeof workflow === "string" ? {} : { skip: true });
|
|
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)) });
|
|
1650
|
-
}
|
|
1651
|
-
/** One property's control, or nothing when its type has no obvious one. */
|
|
1652
|
-
function SchemaField({ name, property, required, upload = false }) {
|
|
1653
|
-
const label = humanize(name);
|
|
1654
|
-
const hint = property.description === void 0 ? {} : { hint: property.description };
|
|
1655
|
-
const defaults = property.default === void 0 ? {} : { defaultValue: String(property.default) };
|
|
1656
|
-
if (upload) return /* @__PURE__ */ jsx(FileField, {
|
|
1657
|
-
name,
|
|
1658
|
-
label,
|
|
1659
|
-
required,
|
|
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;
|
|
1696
|
-
}
|
|
1697
|
-
}
|
|
1698
|
-
/** A property's type, taking the first non-null member of a union. */
|
|
1699
|
-
function typeOf(property) {
|
|
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;
|
|
1707
|
-
}
|
|
1532
|
+
const PREFIX = "aai:upload:";
|
|
1708
1533
|
/**
|
|
1709
|
-
*
|
|
1534
|
+
* How many ids one form keeps.
|
|
1710
1535
|
*
|
|
1711
|
-
* A
|
|
1712
|
-
*
|
|
1536
|
+
* A cap rather than an expiry, because `sessionStorage` already expires with the
|
|
1537
|
+
* tab and an entry is ~80 bytes. What it bounds is the long-lived tab that
|
|
1538
|
+
* submits a hundred files: the oldest go first, and the id most likely to be
|
|
1539
|
+
* worth resuming is the one written last.
|
|
1713
1540
|
*/
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1541
|
+
const MAX_REMEMBERED = 32;
|
|
1542
|
+
/** One form's slot in storage. */
|
|
1543
|
+
function keyFor(scope) {
|
|
1544
|
+
return `${PREFIX}${scope}`;
|
|
1717
1545
|
}
|
|
1718
|
-
//#endregion
|
|
1719
|
-
//#region components/workflow-progress.tsx
|
|
1720
|
-
/** @jsxImportSource react */
|
|
1721
1546
|
/**
|
|
1722
|
-
* What
|
|
1723
|
-
*
|
|
1724
|
-
* The complement of a status line, and the reason both exist: a run is
|
|
1725
|
-
* `running` for its whole life, so a one-round job and a ten-round one look
|
|
1726
|
-
* identical while they happen. These lines come from the run itself (`report()`
|
|
1727
|
-
* in a `"use step"` body), which is the only channel a workflow has before it
|
|
1728
|
-
* produces an output.
|
|
1729
|
-
*
|
|
1730
|
-
* Three rules are baked in, and they are why this is a component rather than
|
|
1731
|
-
* three lines each page writes for itself — the two templates that had written
|
|
1732
|
-
* it had written all three, comments included:
|
|
1733
|
-
*
|
|
1734
|
-
* - **It renders nothing until there is something to render.** `supported` is
|
|
1735
|
-
* what keeps this from being an empty box forever on an agent deployed before
|
|
1736
|
-
* progress streams existed: "wrote nothing yet" and "serves no stream" are
|
|
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.
|
|
1746
|
-
*
|
|
1747
|
-
* @example
|
|
1748
|
-
* ```tsx
|
|
1749
|
-
* import { WorkflowProgress } from "@alexkroman1/aai-ui";
|
|
1750
|
-
*
|
|
1751
|
-
* function RunPanel({ runId }: { runId: string }) {
|
|
1752
|
-
* return <WorkflowProgress runId={runId} />;
|
|
1753
|
-
* }
|
|
1754
|
-
* ```
|
|
1755
|
-
*
|
|
1756
|
-
* @param props - Progress-log props.
|
|
1547
|
+
* What names this file across a reload.
|
|
1757
1548
|
*
|
|
1758
|
-
*
|
|
1549
|
+
* The four fields a browser gives a picked file that do not change between loads.
|
|
1550
|
+
* `name` last because it is the one a person can read in a debugger.
|
|
1759
1551
|
*/
|
|
1760
|
-
function
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1552
|
+
function fingerprint(file) {
|
|
1553
|
+
return `${file.size}:${file.lastModified}:${file.type}:${file.name}`;
|
|
1554
|
+
}
|
|
1555
|
+
/** This scope's remembered ids, or nothing at all — a parse failure is nothing. */
|
|
1556
|
+
function read(scope) {
|
|
1557
|
+
try {
|
|
1558
|
+
const raw = globalThis.sessionStorage?.getItem(keyFor(scope));
|
|
1559
|
+
if (raw === null || raw === void 0) return {};
|
|
1560
|
+
const parsed = JSON.parse(raw);
|
|
1561
|
+
return isRecord(parsed) ? parsed : {};
|
|
1562
|
+
} catch {
|
|
1563
|
+
return {};
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
function write(scope, entries) {
|
|
1567
|
+
try {
|
|
1568
|
+
globalThis.sessionStorage?.setItem(keyFor(scope), JSON.stringify(entries));
|
|
1569
|
+
} catch {}
|
|
1768
1570
|
}
|
|
1769
|
-
//#endregion
|
|
1770
|
-
//#region page.tsx
|
|
1771
|
-
/** @jsxImportSource react */
|
|
1772
1571
|
/**
|
|
1773
|
-
*
|
|
1572
|
+
* The id this file was last being stored under in this tab, if any.
|
|
1774
1573
|
*
|
|
1775
|
-
*
|
|
1776
|
-
* microphone (`workflowApp()`). It is a separate entry rather than
|
|
1777
|
-
* an option on `client()` because of what `client()` unavoidably does: it
|
|
1778
|
-
* constructs a `SessionCore`, which owns a WebSocket URL provider, an audio
|
|
1779
|
-
* graph, and a microphone request. A flag would have to make all of that
|
|
1780
|
-
* conditional, and every session hook would then have to answer "what does this
|
|
1781
|
-
* mean with no session?" — so the honest split is two mounts. A page that wants
|
|
1782
|
-
* voice uses `client()`; a page that wants neither audio nor a socket uses this.
|
|
1574
|
+
* A hit is a CANDIDATE and never a decision — see the module doc.
|
|
1783
1575
|
*
|
|
1784
|
-
*
|
|
1785
|
-
* React, still Tailwind, still the same theme tokens — so a workflow app reads
|
|
1786
|
-
* like every other agent. What it reaches for instead of `useSession()` is
|
|
1787
|
-
* `createWorkflowApi()` / `useWorkflowRun()`.
|
|
1576
|
+
* @internal
|
|
1788
1577
|
*/
|
|
1578
|
+
function recallUploadId(scope, file) {
|
|
1579
|
+
const found = read(scope)[fingerprint(file)];
|
|
1580
|
+
return typeof found === "string" ? found : void 0;
|
|
1581
|
+
}
|
|
1789
1582
|
/**
|
|
1790
|
-
*
|
|
1791
|
-
*
|
|
1792
|
-
* There is deliberately no session, no microphone, and no socket: the component
|
|
1793
|
-
* talks to the agent over the workflow HTTP API
|
|
1794
|
-
* (`createWorkflowApi`/`useWorkflowRun`), which is durable and outlives the tab.
|
|
1795
|
-
*
|
|
1796
|
-
* @example
|
|
1797
|
-
* ```tsx
|
|
1798
|
-
* import { createWorkflowApi, page, useWorkflowRun } from "@alexkroman1/aai-ui";
|
|
1799
|
-
* import { useState } from "react";
|
|
1800
|
-
*
|
|
1801
|
-
* // Hoisted: a client built in render is a new object every render.
|
|
1802
|
-
* const api = createWorkflowApi();
|
|
1583
|
+
* Remember the id this file is being stored under.
|
|
1803
1584
|
*
|
|
1804
|
-
*
|
|
1805
|
-
*
|
|
1806
|
-
*
|
|
1807
|
-
* return (
|
|
1808
|
-
* <button
|
|
1809
|
-
* type="button"
|
|
1810
|
-
* onClick={() => void api.start("digest", { topic: "ai" }).then(setRunId)}
|
|
1811
|
-
* >
|
|
1812
|
-
* {run ? run.status : "Start"}
|
|
1813
|
-
* </button>
|
|
1814
|
-
* );
|
|
1815
|
-
* }
|
|
1585
|
+
* Called before the first byte leaves rather than after the last one lands: the
|
|
1586
|
+
* reload this exists for happens in between, and an id written at the end is an
|
|
1587
|
+
* id written for the one case that did not need it.
|
|
1816
1588
|
*
|
|
1817
|
-
*
|
|
1818
|
-
|
|
1589
|
+
* @internal
|
|
1590
|
+
*/
|
|
1591
|
+
function rememberUploadId(scope, file, id) {
|
|
1592
|
+
const entries = read(scope);
|
|
1593
|
+
const key = fingerprint(file);
|
|
1594
|
+
delete entries[key];
|
|
1595
|
+
entries[key] = id;
|
|
1596
|
+
const keys = Object.keys(entries);
|
|
1597
|
+
for (const stale of keys.slice(0, Math.max(0, keys.length - MAX_REMEMBERED))) delete entries[stale];
|
|
1598
|
+
write(scope, entries);
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* Forget it: the agent holds nothing resumable under this id.
|
|
1819
1602
|
*
|
|
1820
|
-
*
|
|
1603
|
+
* The other half of the agent deciding. Without it a swept upload is re-read on
|
|
1604
|
+
* every submission of the same file for the life of the tab, which is a round
|
|
1605
|
+
* trip spent learning the same 404.
|
|
1821
1606
|
*
|
|
1822
|
-
* @
|
|
1607
|
+
* @internal
|
|
1823
1608
|
*/
|
|
1824
|
-
function
|
|
1825
|
-
const
|
|
1826
|
-
|
|
1827
|
-
|
|
1609
|
+
function forgetUploadId(scope, file) {
|
|
1610
|
+
const entries = read(scope);
|
|
1611
|
+
const key = fingerprint(file);
|
|
1612
|
+
if (!(key in entries)) return;
|
|
1613
|
+
delete entries[key];
|
|
1614
|
+
write(scope, entries);
|
|
1828
1615
|
}
|
|
1829
1616
|
//#endregion
|
|
1830
|
-
//#region
|
|
1617
|
+
//#region src/_upload-session.ts
|
|
1618
|
+
/** Whether this rejection is an abort, in either of the two shapes runtimes throw. */
|
|
1619
|
+
function isAbortError(err) {
|
|
1620
|
+
return err instanceof Error && err.name === "AbortError";
|
|
1621
|
+
}
|
|
1622
|
+
/** A fresh upload id: a capability, so it is random rather than derived. */
|
|
1623
|
+
function randomUploadId() {
|
|
1624
|
+
return crypto.randomUUID().replaceAll("-", "");
|
|
1625
|
+
}
|
|
1831
1626
|
/**
|
|
1832
|
-
*
|
|
1833
|
-
* `<img>` or `<a download>` will accept.
|
|
1834
|
-
*
|
|
1835
|
-
* `api.download(id)` resolves a `Blob`, and it has to: the byte route takes the
|
|
1836
|
-
* same bearer every workflow route does, and neither `<audio src>` nor
|
|
1837
|
-
* `<a href>` can send one. So every page that plays back what a run WROTE ends
|
|
1838
|
-
* up at the same four lines — `download` → `createObjectURL` → state — and the
|
|
1839
|
-
* two that are really the point are the two the four lines are wrapped in:
|
|
1627
|
+
* Send one file, waiting out however many pauses the person takes.
|
|
1840
1628
|
*
|
|
1841
|
-
*
|
|
1842
|
-
*
|
|
1843
|
-
*
|
|
1844
|
-
*
|
|
1845
|
-
*
|
|
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.
|
|
1629
|
+
* The loop from the module doc, written once: both hooks need exactly this and a
|
|
1630
|
+
* second copy of it is a second place for the abort/pause distinction to be got
|
|
1631
|
+
* wrong. `send` is handed whether this attempt must CLAIM the id as its own —
|
|
1632
|
+
* false the first time, since a fresh id has nothing to resume and saying
|
|
1633
|
+
* otherwise waives the refusal that makes a caller-chosen id safe.
|
|
1849
1634
|
*
|
|
1850
|
-
*
|
|
1851
|
-
*
|
|
1852
|
-
*
|
|
1853
|
-
* "downloading" as the same thing — so this reports it.
|
|
1635
|
+
* Throws whatever `send` threw, except an abort the gate caused. A cancelled gate
|
|
1636
|
+
* throws too: the caller distinguishes it by reading `gate.cancelled`, which is
|
|
1637
|
+
* how an abandoned submission unwinds without being reported as a failure.
|
|
1854
1638
|
*/
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1639
|
+
async function sendThroughGate(gate, send) {
|
|
1640
|
+
let tried = false;
|
|
1641
|
+
for (;;) {
|
|
1642
|
+
await gate.settle();
|
|
1643
|
+
if (gate.cancelled) throw new Error("Upload cancelled.");
|
|
1644
|
+
const resume = tried;
|
|
1645
|
+
tried = true;
|
|
1646
|
+
try {
|
|
1647
|
+
await send(resume);
|
|
1648
|
+
return;
|
|
1649
|
+
} catch (err) {
|
|
1650
|
+
if (gate.cancelled || !isAbortError(err)) throw err;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1859
1654
|
/**
|
|
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";
|
|
1872
|
-
*
|
|
1873
|
-
* function Playback() {
|
|
1874
|
-
* const { run } = useWorkflowSubmit<typeof spokenSummary>("spokenSummary");
|
|
1875
|
-
* const output = run?.status === "completed" ? run.output : undefined;
|
|
1876
|
-
* const audio = useDownloadUrl(output?.audio);
|
|
1877
|
-
* if (audio.pending) return <p>Fetching audio…</p>;
|
|
1878
|
-
* if (audio.error !== undefined) return <p role="alert">{audio.error}</p>;
|
|
1879
|
-
* return audio.url === undefined ? null : (
|
|
1880
|
-
* <a href={audio.url} download="summary.mp3">
|
|
1881
|
-
* Download
|
|
1882
|
-
* </a>
|
|
1883
|
-
* );
|
|
1884
|
-
* }
|
|
1885
|
-
* ```
|
|
1655
|
+
* A gate, open.
|
|
1886
1656
|
*
|
|
1887
|
-
*
|
|
1657
|
+
* One per upload rather than one per hook: the id and the windows already stored
|
|
1658
|
+
* belong to a file, so a gate that outlived its file would resume something else.
|
|
1888
1659
|
*/
|
|
1889
|
-
function
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1660
|
+
function createUploadGate() {
|
|
1661
|
+
let controller = new AbortController();
|
|
1662
|
+
let paused = false;
|
|
1663
|
+
let cancelled = false;
|
|
1664
|
+
let open;
|
|
1665
|
+
let closed;
|
|
1666
|
+
return {
|
|
1667
|
+
get paused() {
|
|
1668
|
+
return paused;
|
|
1669
|
+
},
|
|
1670
|
+
get cancelled() {
|
|
1671
|
+
return cancelled;
|
|
1672
|
+
},
|
|
1673
|
+
get signal() {
|
|
1674
|
+
return controller.signal;
|
|
1675
|
+
},
|
|
1676
|
+
pause() {
|
|
1677
|
+
if (paused || cancelled) return;
|
|
1678
|
+
paused = true;
|
|
1679
|
+
const gate = Promise.withResolvers();
|
|
1680
|
+
closed = gate.promise;
|
|
1681
|
+
open = gate.resolve;
|
|
1682
|
+
controller.abort();
|
|
1683
|
+
},
|
|
1684
|
+
resume() {
|
|
1685
|
+
if (!paused || cancelled) return;
|
|
1686
|
+
paused = false;
|
|
1687
|
+
controller = new AbortController();
|
|
1688
|
+
open?.();
|
|
1689
|
+
open = void 0;
|
|
1690
|
+
closed = void 0;
|
|
1691
|
+
},
|
|
1692
|
+
cancel() {
|
|
1901
1693
|
if (cancelled) return;
|
|
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
1694
|
cancelled = true;
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1695
|
+
paused = false;
|
|
1696
|
+
controller.abort();
|
|
1697
|
+
open?.();
|
|
1698
|
+
open = void 0;
|
|
1699
|
+
closed = void 0;
|
|
1700
|
+
},
|
|
1701
|
+
async settle() {
|
|
1702
|
+
if (closed) await closed;
|
|
1703
|
+
}
|
|
1704
|
+
};
|
|
1919
1705
|
}
|
|
1920
1706
|
//#endregion
|
|
1921
|
-
//#region
|
|
1707
|
+
//#region src/_workflow-files.ts
|
|
1922
1708
|
/**
|
|
1923
|
-
*
|
|
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.
|
|
1709
|
+
* Which of a submitted form's values are FILES.
|
|
1944
1710
|
*
|
|
1945
|
-
*
|
|
1946
|
-
*
|
|
1947
|
-
*
|
|
1948
|
-
*
|
|
1949
|
-
*
|
|
1950
|
-
*
|
|
1951
|
-
|
|
1711
|
+
* Its own module because both submit hooks need the identical answer and then do
|
|
1712
|
+
* two different things with it — `useWorkflowSubmit` stores each file and passes
|
|
1713
|
+
* its id, `useWorkflowStream` cuts it into parts and passes the group they share.
|
|
1714
|
+
* A second copy of this predicate would be a form field that one hook treats as a
|
|
1715
|
+
* file and the other does not, which is invisible until the run reads the wrong
|
|
1716
|
+
* kind of string.
|
|
1717
|
+
*/
|
|
1718
|
+
/**
|
|
1719
|
+
* The files a submitted field carries, if that is what it carries.
|
|
1952
1720
|
*
|
|
1953
|
-
*
|
|
1721
|
+
* An array counts only when it is files ALL the way through — a mixed array is
|
|
1722
|
+
* some other field's value that happens to contain one, and turning half of it
|
|
1723
|
+
* into ids would corrupt it silently.
|
|
1724
|
+
*/
|
|
1725
|
+
function filesOf(value) {
|
|
1726
|
+
if (value instanceof File) return [value];
|
|
1727
|
+
if (!Array.isArray(value)) return [];
|
|
1728
|
+
const files = value.filter((one) => one instanceof File);
|
|
1729
|
+
return files.length > 0 && files.length === value.length ? files : [];
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* The input properties still carrying a `File` — i.e. the ones that CANNOT survive
|
|
1733
|
+
* being sent.
|
|
1954
1734
|
*
|
|
1955
|
-
* `
|
|
1956
|
-
*
|
|
1957
|
-
*
|
|
1958
|
-
*
|
|
1959
|
-
*
|
|
1735
|
+
* A run input is JSON, and `JSON.stringify(new File(…))` is `{}` — no `toJSON`, no
|
|
1736
|
+
* own enumerable properties. So a File left in a payload does not fail to send: it
|
|
1737
|
+
* arrives as an empty object, and the workflow rejects it against whatever its own
|
|
1738
|
+
* schema says the property should be. Measured in production as
|
|
1739
|
+
* `Invalid input for workflow "transcribe": recording: Invalid input` — a message
|
|
1740
|
+
* about a type, on a form where the user had picked a perfectly good file.
|
|
1960
1741
|
*
|
|
1961
|
-
*
|
|
1962
|
-
*
|
|
1963
|
-
*
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1742
|
+
* Exported beside {@link filesOf} because it is the same question asked at the
|
|
1743
|
+
* other end: that one decides which fields to UPLOAD, this one checks that none
|
|
1744
|
+
* were missed. Both hooks are the callers.
|
|
1745
|
+
*/
|
|
1746
|
+
function fileFields(input) {
|
|
1747
|
+
if (!isRecord(input)) return [];
|
|
1748
|
+
return Object.entries(input).filter(([, value]) => filesOf(value).length > 0).map(([key]) => key);
|
|
1749
|
+
}
|
|
1750
|
+
//#endregion
|
|
1751
|
+
//#region src/_upload-files.ts
|
|
1752
|
+
/**
|
|
1753
|
+
* Turning a form's `File`s into stored upload ids, pauses and all.
|
|
1967
1754
|
*
|
|
1968
|
-
*
|
|
1755
|
+
* Split out of `use-workflow-form.ts` for the 500-line cap, and the seam is a
|
|
1756
|
+
* real one: that module is the two HOOKS and the state between them, where this
|
|
1757
|
+
* is the walk over a submitted input — which is the only part of it that knows
|
|
1758
|
+
* what a `File` is, holds a loop, and survives being re-entered.
|
|
1969
1759
|
*
|
|
1970
|
-
*
|
|
1971
|
-
*
|
|
1972
|
-
*
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1760
|
+
* `_`-internal. `useWorkflowSubmit` is the only caller; `useWorkflowStream` sends
|
|
1761
|
+
* one file rather than walking an input and shares only the gate underneath both
|
|
1762
|
+
* (`_upload-session.ts`).
|
|
1763
|
+
*/
|
|
1764
|
+
/** A fresh session for one submission of `workflow`. */
|
|
1765
|
+
function createUploadSession(workflow) {
|
|
1766
|
+
return {
|
|
1767
|
+
scope: workflow,
|
|
1768
|
+
ids: /* @__PURE__ */ new Map(),
|
|
1769
|
+
stored: /* @__PURE__ */ new Map(),
|
|
1770
|
+
tried: /* @__PURE__ */ new Set(),
|
|
1771
|
+
gate: createUploadGate()
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* The id to store this file under: the one a previous page load was using, or a
|
|
1776
|
+
* fresh one.
|
|
1980
1777
|
*
|
|
1981
|
-
*
|
|
1778
|
+
* **The AGENT decides, never the fingerprint.** Two files can agree on every
|
|
1779
|
+
* field `_upload-recall.ts` keys by, so the recalled id is a candidate that has to
|
|
1780
|
+
* be checked before a byte is sent to it — and the check is cheap and exact,
|
|
1781
|
+
* because `uploadInfo` is the same record a resume already reads.
|
|
1982
1782
|
*
|
|
1983
|
-
*
|
|
1984
|
-
* would have two agents scaffolded from the same template recover each other's
|
|
1985
|
-
* runs. The key is the page's own directory — resolved through `"./"`, which
|
|
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.
|
|
1783
|
+
* Three answers, and the third is why this is not just a storage lookup:
|
|
1989
1784
|
*
|
|
1990
|
-
*
|
|
1991
|
-
*
|
|
1992
|
-
*
|
|
1993
|
-
*
|
|
1994
|
-
*
|
|
1995
|
-
*
|
|
1996
|
-
*
|
|
1997
|
-
*
|
|
1998
|
-
*
|
|
1785
|
+
* - **Complete.** Every byte is in from a load that is gone, so there is nothing
|
|
1786
|
+
* to send: the caller takes the id and starts the run. This is the refresh that
|
|
1787
|
+
* costs one `GET` instead of a second 200 MB upload.
|
|
1788
|
+
* - **Unfinished, with windows.** `UploadInfo.ranges` is what makes an
|
|
1789
|
+
* upload resumable at all, so the id is reused and the attempt claims it.
|
|
1790
|
+
* - **Anything else.** A 404 (swept, or never seen), a failure, or an unfinished
|
|
1791
|
+
* upload reporting NO windows — which is a partial single `PUT`, and a second
|
|
1792
|
+
* `PUT` to that id is a 409 rather than an append (`streamUploadFile`). Reusing
|
|
1793
|
+
* it would turn a reload into a failure the person cannot clear, so the entry is
|
|
1794
|
+
* dropped and the file gets a fresh id.
|
|
1999
1795
|
*/
|
|
2000
|
-
|
|
2001
|
-
const
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
1796
|
+
async function claimId(api, session, file) {
|
|
1797
|
+
const remembered = recallUploadId(session.scope, file);
|
|
1798
|
+
if (remembered === void 0) return {
|
|
1799
|
+
id: randomUploadId(),
|
|
1800
|
+
complete: false
|
|
1801
|
+
};
|
|
1802
|
+
const info = await api.uploadInfo(remembered).catch(() => void 0);
|
|
1803
|
+
if (info?.complete === true) return {
|
|
1804
|
+
id: remembered,
|
|
1805
|
+
complete: true
|
|
1806
|
+
};
|
|
1807
|
+
if (info !== void 0 && (info.ranges?.length ?? 0) > 0) {
|
|
1808
|
+
session.tried.add(file);
|
|
1809
|
+
return {
|
|
1810
|
+
id: remembered,
|
|
1811
|
+
complete: false
|
|
1812
|
+
};
|
|
2010
1813
|
}
|
|
1814
|
+
forgetUploadId(session.scope, file);
|
|
1815
|
+
return {
|
|
1816
|
+
id: randomUploadId(),
|
|
1817
|
+
complete: false
|
|
1818
|
+
};
|
|
2011
1819
|
}
|
|
2012
1820
|
/**
|
|
2013
|
-
*
|
|
1821
|
+
* Replace every `File` in a submitted form with the id of a stored upload,
|
|
1822
|
+
* reporting how far each one has got.
|
|
2014
1823
|
*
|
|
2015
|
-
*
|
|
2016
|
-
*
|
|
2017
|
-
*
|
|
1824
|
+
* Sequential rather than `Promise.all`: these are large bodies, and a form with
|
|
1825
|
+
* two 200 MB recordings should send them one after another rather than compete
|
|
1826
|
+
* for the same connection. That is also what makes a single bar honest — one
|
|
1827
|
+
* file is in flight at a time, and `index`/`count` say which.
|
|
1828
|
+
*
|
|
1829
|
+
* Anything that is not a `File` (or an array of them) passes through untouched,
|
|
1830
|
+
* so this is invisible to every form that has none — including one whose values
|
|
1831
|
+
* are not an object at all, which `submit` accepts.
|
|
1832
|
+
*
|
|
1833
|
+
* ## `uploadStream`, not `upload`, and the id is the reason
|
|
1834
|
+
*
|
|
1835
|
+
* The difference between the two calls is only who mints the id — and that is
|
|
1836
|
+
* exactly what decides whether an interrupted upload can be picked up again. An
|
|
1837
|
+
* `upload` mints its own at the END and hands it back, so a caller whose upload
|
|
1838
|
+
* died has nothing to name what was stored and no choice but to send the file
|
|
1839
|
+
* again. A `uploadStream` is told the id up front, so the windows already in the
|
|
1840
|
+
* store are addressable, which is what both a pause and a server restart need.
|
|
1841
|
+
*
|
|
1842
|
+
* Nothing else about the submission changes: the run is still started after the
|
|
1843
|
+
* last byte lands, so the incomplete record a streamed upload leaves along the
|
|
1844
|
+
* way is one nobody reads.
|
|
2018
1845
|
*/
|
|
2019
|
-
function
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
return
|
|
2028
|
-
|
|
2029
|
-
|
|
1846
|
+
async function uploadFiles(api, input, report, parallel, session) {
|
|
1847
|
+
if (!isRecord(input)) return input;
|
|
1848
|
+
const entries = Object.entries(input);
|
|
1849
|
+
const count = entries.reduce((total, [, value]) => total + filesOf(value).length, 0);
|
|
1850
|
+
let index = 0;
|
|
1851
|
+
const store = async (file) => {
|
|
1852
|
+
index += 1;
|
|
1853
|
+
const done = session.stored.get(file);
|
|
1854
|
+
if (done !== void 0) return done;
|
|
1855
|
+
const position = {
|
|
1856
|
+
name: file.name,
|
|
1857
|
+
index,
|
|
1858
|
+
count
|
|
1859
|
+
};
|
|
1860
|
+
const known = session.ids.get(file);
|
|
1861
|
+
const claimed = known === void 0 ? await claimId(api, session, file) : {
|
|
1862
|
+
id: known,
|
|
1863
|
+
complete: false
|
|
1864
|
+
};
|
|
1865
|
+
const id = claimed.id;
|
|
1866
|
+
if (known === void 0) {
|
|
1867
|
+
session.ids.set(file, id);
|
|
1868
|
+
rememberUploadId(session.scope, file, id);
|
|
1869
|
+
}
|
|
1870
|
+
if (claimed.complete) {
|
|
1871
|
+
report({
|
|
1872
|
+
...position,
|
|
1873
|
+
loaded: file.size,
|
|
1874
|
+
total: file.size,
|
|
1875
|
+
fraction: 1,
|
|
1876
|
+
paused: false
|
|
1877
|
+
});
|
|
1878
|
+
session.stored.set(file, id);
|
|
1879
|
+
return id;
|
|
1880
|
+
}
|
|
1881
|
+
await sendThroughGate(session.gate, async (resume) => {
|
|
1882
|
+
await api.uploadStream(id, file, {
|
|
1883
|
+
name: file.name,
|
|
1884
|
+
signal: session.gate.signal,
|
|
1885
|
+
onProgress: (progress) => report({
|
|
1886
|
+
...position,
|
|
1887
|
+
...progress,
|
|
1888
|
+
paused: session.gate.paused
|
|
1889
|
+
}),
|
|
1890
|
+
...omitUndefined({
|
|
1891
|
+
parallel,
|
|
1892
|
+
resume: resume || session.tried.has(file) ? true : void 0
|
|
1893
|
+
})
|
|
1894
|
+
});
|
|
1895
|
+
});
|
|
1896
|
+
session.stored.set(file, id);
|
|
1897
|
+
return id;
|
|
1898
|
+
};
|
|
1899
|
+
const out = {};
|
|
1900
|
+
for (const [name, value] of entries) {
|
|
1901
|
+
if (value instanceof File) {
|
|
1902
|
+
out[name] = await store(value);
|
|
1903
|
+
continue;
|
|
1904
|
+
}
|
|
1905
|
+
const chosen = filesOf(value);
|
|
1906
|
+
if (chosen.length === 0) {
|
|
1907
|
+
out[name] = value;
|
|
1908
|
+
continue;
|
|
1909
|
+
}
|
|
1910
|
+
const ids = [];
|
|
1911
|
+
for (const file of chosen) ids.push(await store(file));
|
|
1912
|
+
out[name] = ids;
|
|
2030
1913
|
}
|
|
1914
|
+
return out;
|
|
2031
1915
|
}
|
|
1916
|
+
//#endregion
|
|
1917
|
+
//#region src/_upload-pause.ts
|
|
2032
1918
|
/**
|
|
2033
|
-
*
|
|
2034
|
-
*
|
|
1919
|
+
* The two buttons a page puts over a live upload, bound to whichever gate the
|
|
1920
|
+
* submission is currently holding.
|
|
2035
1921
|
*
|
|
2036
|
-
*
|
|
2037
|
-
*
|
|
2038
|
-
*
|
|
2039
|
-
*
|
|
2040
|
-
*
|
|
2041
|
-
*
|
|
1922
|
+
* `useWorkflowSubmit` and `useWorkflowStream` both own an {@link UploadGate} and
|
|
1923
|
+
* both reported the pause the same way: park the gate, then fold `paused` into
|
|
1924
|
+
* the status the bar is already drawing. Folded rather than replaced, because
|
|
1925
|
+
* everything else about that status — which file, how far, of how many — is
|
|
1926
|
+
* still true. Two copies of that rule are two copies that can stop agreeing
|
|
1927
|
+
* about what a paused bar says.
|
|
2042
1928
|
*/
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
1929
|
+
/**
|
|
1930
|
+
* Bind pause/resume to the live submission's gate.
|
|
1931
|
+
*
|
|
1932
|
+
* @param getGate - Reads the gate out of the caller's ref, so the callbacks stay
|
|
1933
|
+
* stable across submissions rather than being re-created per gate.
|
|
1934
|
+
* @param setUpload - The status setter the progress bar renders from.
|
|
1935
|
+
*
|
|
1936
|
+
* @internal
|
|
1937
|
+
*/
|
|
1938
|
+
function useUploadPause(getGate, setUpload) {
|
|
1939
|
+
const setPaused = useCallback((paused) => {
|
|
1940
|
+
setUpload((current) => current ? {
|
|
1941
|
+
...current,
|
|
1942
|
+
paused
|
|
1943
|
+
} : current);
|
|
1944
|
+
}, [setUpload]);
|
|
1945
|
+
return {
|
|
1946
|
+
pauseUpload: useCallback(() => {
|
|
1947
|
+
getGate()?.pause();
|
|
1948
|
+
setPaused(true);
|
|
1949
|
+
}, [getGate, setPaused]),
|
|
1950
|
+
resumeUpload: useCallback(() => {
|
|
1951
|
+
getGate()?.resume();
|
|
1952
|
+
setPaused(false);
|
|
1953
|
+
}, [getGate, setPaused])
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1956
|
+
//#endregion
|
|
1957
|
+
//#region src/use-workflow-form.ts
|
|
1958
|
+
/**
|
|
1959
|
+
* The hook a FORM needs, as against the one a status view does.
|
|
1960
|
+
*
|
|
1961
|
+
* `useWorkflowRun` (`workflow-client.ts`) watches a run you already have.
|
|
1962
|
+
* This is what comes before it: `useWorkflowSubmit` starts a run and hands the
|
|
1963
|
+
* id straight to `useWorkflowRun`. Its sibling `useWorkflows` — the listing
|
|
1964
|
+
* `<WorkflowFields>` renders a form from — is `use-workflows.ts`.
|
|
1965
|
+
*
|
|
1966
|
+
* ## `useWorkflowSubmit` — a form's two halves in one hook
|
|
1967
|
+
*
|
|
1968
|
+
* A page that submits a workflow always needs the same four pieces of state:
|
|
1969
|
+
* the run id, whether a submit is in flight, whether the RUN is still going, and
|
|
1970
|
+
* whichever of the two failed. `link-digest` writes them out by hand, which is
|
|
1971
|
+
* the right shape for a template teaching the primitives and the wrong shape to
|
|
1972
|
+
* write a third time — and it is easy to get subtly wrong: dropping the previous
|
|
1973
|
+
* run id before the new `POST` returns is what stops a finished result sitting
|
|
1974
|
+
* under a form that is already submitting again.
|
|
1975
|
+
*
|
|
1976
|
+
* So this is `api.start` plus {@link useWorkflowRun}, with the state between
|
|
1977
|
+
* them. It adds no transport of its own and holds no run state of its own; the
|
|
1978
|
+
* watching (stream first, poll as its fallback, terminal stops) is entirely
|
|
1979
|
+
* `useWorkflowRun`'s, and `run` here IS its run.
|
|
1980
|
+
*
|
|
1981
|
+
* ## Why it starts ASYNCHRONOUSLY even though a synchronous call exists
|
|
1982
|
+
*
|
|
1983
|
+
* `api.startAndWait` would collapse this to one request, and it is the wrong
|
|
1984
|
+
* default for a page: it holds a socket open for up to a minute, answers nothing
|
|
1985
|
+
* until it settles, and a page has `useWorkflowRun` — which survives a reload,
|
|
1986
|
+
* shows progress, and costs one stream. The synchronous call is for callers with
|
|
1987
|
+
* nowhere to put a watch (a script, a cron, a form POST from a server). Pass
|
|
1988
|
+
* `wait` here when the page really does want one request, and the run is
|
|
1989
|
+
* followed from the same id either way.
|
|
1990
|
+
*/
|
|
1991
|
+
/**
|
|
1992
|
+
* Start a workflow from a form, and follow the run it creates.
|
|
1993
|
+
*
|
|
1994
|
+
* @typeParam D - The workflow DEFINITION, which types both halves of the
|
|
1995
|
+
* submission: `submit(input)` takes what the workflow's schema parses to, and
|
|
1996
|
+
* `run.status === "completed"` narrows to a typed `run.output`.
|
|
1997
|
+
*
|
|
1998
|
+
* It used to be the OUTPUT type alone, and the asymmetry was the bug: a page
|
|
1999
|
+
* already wrote `WorkflowOutputOf<typeof digest>` to get the output, while
|
|
2000
|
+
* `submit` took `unknown`, so `submit({ ur1: 42 })` compiled and arrived as a
|
|
2001
|
+
* 400 in the browser. Naming the def instead types the input from the same
|
|
2002
|
+
* declaration — and `import type` is ERASED, so it costs the bundle nothing.
|
|
2003
|
+
* Passing an output type where a def belongs is now a compile error rather
|
|
2004
|
+
* than a silent loss of typing, which is the point.
|
|
2005
|
+
*
|
|
2006
|
+
* @example
|
|
2007
|
+
* ```tsx no-check
|
|
2008
|
+
* import { Form, SubmitButton, TextField, useWorkflowSubmit } from "@alexkroman1/aai-ui";
|
|
2009
|
+
* import type { digest } from "./agent.ts";
|
|
2010
|
+
*
|
|
2011
|
+
* function DigestForm() {
|
|
2012
|
+
* const { submit, run, pending, error } = useWorkflowSubmit<typeof digest>("digest");
|
|
2013
|
+
* return (
|
|
2014
|
+
* <Form onSubmit={(values) => submit(values)} error={error}>
|
|
2015
|
+
* <TextField name="url" label="Link" type="url" required />
|
|
2016
|
+
* <SubmitButton pending={pending}>Digest</SubmitButton>
|
|
2017
|
+
* {run?.status === "completed" && <p>{run.output.title}</p>}
|
|
2018
|
+
* </Form>
|
|
2019
|
+
* );
|
|
2020
|
+
* }
|
|
2021
|
+
* ```
|
|
2022
|
+
*
|
|
2023
|
+
* @public
|
|
2024
|
+
*/
|
|
2025
|
+
function useWorkflowSubmit(workflow, opts = {}) {
|
|
2026
|
+
const { api, recover = true, wait, intervalMs, parallel } = opts;
|
|
2027
|
+
const key = useDefaultRunKey(opts.key);
|
|
2028
|
+
const [runId, setRunId] = useState(void 0);
|
|
2029
|
+
const [starting, setStarting] = useState(false);
|
|
2030
|
+
const [startError, setStartError] = useState(void 0);
|
|
2031
|
+
const [upload, setUpload] = useState(void 0);
|
|
2032
|
+
const session = useRef(void 0);
|
|
2033
|
+
const getClient = useWorkflowApiRef(api);
|
|
2034
|
+
const tracked = useWorkflowRun(runId, omitUndefined({
|
|
2035
|
+
api,
|
|
2036
|
+
intervalMs
|
|
2037
|
+
}));
|
|
2038
|
+
const { wake, cancel } = useRunControls(runId, getClient);
|
|
2039
|
+
const recovering = useRecoveredRun({
|
|
2040
|
+
workflow,
|
|
2041
|
+
key,
|
|
2042
|
+
enabled: recover,
|
|
2043
|
+
getClient,
|
|
2044
|
+
onFound: (found) => {
|
|
2045
|
+
setRunId((current) => current ?? found);
|
|
2046
|
+
},
|
|
2047
|
+
onError: setStartError
|
|
2048
|
+
});
|
|
2049
|
+
const submit = useCallback(async (input) => {
|
|
2050
|
+
const client = getClient();
|
|
2051
|
+
setStarting(true);
|
|
2052
|
+
setStartError(void 0);
|
|
2053
|
+
setRunId(void 0);
|
|
2054
|
+
session.current?.gate.cancel();
|
|
2055
|
+
const current = createUploadSession(workflow);
|
|
2056
|
+
session.current = current;
|
|
2057
|
+
try {
|
|
2058
|
+
const options = omitUndefined({ key });
|
|
2059
|
+
const started = await uploadFiles(client, input, setUpload, parallel, current);
|
|
2060
|
+
setRunId(wait === void 0 ? await client.start(workflow, started, options) : (await client.startAndWait(workflow, started, {
|
|
2061
|
+
...options,
|
|
2062
|
+
wait
|
|
2063
|
+
})).runId);
|
|
2064
|
+
} catch (err) {
|
|
2065
|
+
if (!current.gate.cancelled) setStartError(errorMessage(err));
|
|
2066
|
+
} finally {
|
|
2067
|
+
if (session.current === current) {
|
|
2068
|
+
session.current = void 0;
|
|
2069
|
+
setStarting(false);
|
|
2070
|
+
setUpload(void 0);
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
}, [
|
|
2074
|
+
workflow,
|
|
2075
|
+
key,
|
|
2076
|
+
wait,
|
|
2077
|
+
parallel,
|
|
2078
|
+
getClient
|
|
2079
|
+
]);
|
|
2080
|
+
const reset = useCallback(() => {
|
|
2081
|
+
session.current?.gate.cancel();
|
|
2082
|
+
session.current = void 0;
|
|
2083
|
+
setRunId(void 0);
|
|
2084
|
+
setStartError(void 0);
|
|
2085
|
+
setUpload(void 0);
|
|
2086
|
+
}, []);
|
|
2087
|
+
const { pauseUpload, resumeUpload } = useUploadPause(useCallback(() => session.current?.gate, []), setUpload);
|
|
2088
|
+
return {
|
|
2089
|
+
submit,
|
|
2090
|
+
submitForm: submit,
|
|
2091
|
+
reset,
|
|
2092
|
+
wake,
|
|
2093
|
+
cancel,
|
|
2094
|
+
pauseUpload,
|
|
2095
|
+
resumeUpload,
|
|
2096
|
+
run: tracked.run,
|
|
2097
|
+
pending: recovering || starting || tracked.polling,
|
|
2098
|
+
upload,
|
|
2099
|
+
error: startError ?? tracked.error
|
|
2100
|
+
};
|
|
2047
2101
|
}
|
|
2048
2102
|
//#endregion
|
|
2049
|
-
//#region use-workflow-runs.ts
|
|
2103
|
+
//#region src/use-workflow-runs.ts
|
|
2050
2104
|
/**
|
|
2051
2105
|
* The RUNS a workflow has had — the list a page shows beside its form.
|
|
2052
2106
|
*
|
|
@@ -2144,7 +2198,7 @@ function useWorkflowRuns(workflow, opts = {}) {
|
|
|
2144
2198
|
};
|
|
2145
2199
|
}
|
|
2146
2200
|
//#endregion
|
|
2147
|
-
//#region use-workflow-stream.ts
|
|
2201
|
+
//#region src/use-workflow-stream.ts
|
|
2148
2202
|
/**
|
|
2149
2203
|
* Starting a run BEFORE its file has finished uploading.
|
|
2150
2204
|
*
|
|
@@ -2423,7 +2477,7 @@ function fileAt(input, field) {
|
|
|
2423
2477
|
return filesOf(input[field])[0];
|
|
2424
2478
|
}
|
|
2425
2479
|
//#endregion
|
|
2426
|
-
//#region workflow-status-labels.ts
|
|
2480
|
+
//#region src/workflow-status-labels.ts
|
|
2427
2481
|
/**
|
|
2428
2482
|
* The default status line per {@link WorkflowRunStatus}.
|
|
2429
2483
|
*
|