@alexkroman1/aai-cli 6.3.1 → 6.5.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/scaffold/package.json +3 -3
- package/dist/templates/transcription-workflow/agent.test.ts +324 -11
- package/dist/templates/transcription-workflow/agent.ts +76 -1
- package/dist/templates/transcription-workflow/api-help.tsx +214 -0
- package/dist/templates/transcription-workflow/client.tsx +292 -8
- package/dist/templates/transcription-workflow/workflows/batch.ts +301 -0
- package/dist/templates/transcription-workflow/workflows/stitch.ts +133 -0
- package/dist/templates/transcription-workflow/workflows/stream.ts +350 -0
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +139 -0
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +200 -183
- package/package.json +3 -3
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* "You do not need this page" — the HTTP API, on the page.
|
|
4
|
+
*
|
|
5
|
+
* A workflow app's whole surface is `GET|POST /workflows/*`, and the page is one
|
|
6
|
+
* caller of it. That is the most useful thing about the shape and the least
|
|
7
|
+
* discoverable: nothing in a form suggests that the same work is three `curl`
|
|
8
|
+
* calls, that a run id is the entire handle (no session, no cookie), or that a
|
|
9
|
+
* transcript can be collected days later from another machine. So the recipes are
|
|
10
|
+
* rendered where somebody is already looking, rather than left in a README they
|
|
11
|
+
* would have to know exists.
|
|
12
|
+
*
|
|
13
|
+
* ## It links to the LIVE listing
|
|
14
|
+
*
|
|
15
|
+
* `GET /workflows` serves each workflow's name, description and input schema — the
|
|
16
|
+
* same JSON `<WorkflowFields>` renders this form from. So the link is not
|
|
17
|
+
* documentation about the API, it is the API answering for itself, on this
|
|
18
|
+
* deployment, at this version. A reader who wants the schema gets the real one;
|
|
19
|
+
* a reader whose agent is behind `AAI_WORKFLOW_API_TOKEN` gets a 401, which is
|
|
20
|
+
* also the truth.
|
|
21
|
+
*
|
|
22
|
+
* ## The three recipes are the three flows
|
|
23
|
+
*
|
|
24
|
+
* The first two differ only in who names the upload — which is the whole of what
|
|
25
|
+
* lets one of them start before the bytes are in — and the third does none of that
|
|
26
|
+
* work at all. Showing them side by side is the clearest statement of the trade
|
|
27
|
+
* available, and cheaper than the prose that would otherwise have to make it.
|
|
28
|
+
*
|
|
29
|
+
* They are also KEPT HONEST by being runnable: every one of these was executed
|
|
30
|
+
* against a real dev server, which is how two bugs in the streaming path were
|
|
31
|
+
* found (a missing wake after the upload, and a poll that slept on a stale view).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** @jsxImportSource react */
|
|
35
|
+
|
|
36
|
+
import type { ReactNode } from "react";
|
|
37
|
+
|
|
38
|
+
/** The API root, relative to wherever this page is served from. */
|
|
39
|
+
const API = "workflows";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One shell recipe, with a heading and a note.
|
|
43
|
+
*
|
|
44
|
+
* Rendered in a `<pre>` rather than assembled from styled spans: it exists to be
|
|
45
|
+
* SELECTED and pasted, and any markup inside the block is markup a copy picks up.
|
|
46
|
+
*/
|
|
47
|
+
function Recipe({
|
|
48
|
+
title,
|
|
49
|
+
note,
|
|
50
|
+
script,
|
|
51
|
+
}: {
|
|
52
|
+
title: string;
|
|
53
|
+
note: string;
|
|
54
|
+
script: string;
|
|
55
|
+
}): ReactNode {
|
|
56
|
+
return (
|
|
57
|
+
<section className="flex flex-col gap-2">
|
|
58
|
+
<h4 className="text-xs font-medium uppercase tracking-[1.2px]">{title}</h4>
|
|
59
|
+
<p className="text-xs opacity-70">{note}</p>
|
|
60
|
+
<pre className="overflow-x-auto rounded-md border p-3 text-xs leading-relaxed">{script}</pre>
|
|
61
|
+
</section>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Store the whole file, then start a run on its id. */
|
|
66
|
+
const CLASSIC = `# 1. store the recording. Answers 201 once the LAST byte is in, which is why
|
|
67
|
+
# this shape cannot start the run any earlier.
|
|
68
|
+
ID=$(curl -s -X POST "$AGENT/workflows/uploads?name=standup.wav" \
|
|
69
|
+
-H 'content-type: audio/wav' --data-binary @standup.wav | jq -r .id)
|
|
70
|
+
|
|
71
|
+
# 2. run it. \`wait\` holds the request open for up to 60s; drop it to get
|
|
72
|
+
# { runId } straight back and poll instead.
|
|
73
|
+
curl -s -X POST "$AGENT/workflows/runs" \
|
|
74
|
+
-H 'content-type: application/json' \
|
|
75
|
+
-d "{\\"workflow\\":\\"transcribe\\",\\"wait\\":60000,
|
|
76
|
+
\\"input\\":{\\"recording\\":\\"$ID\\"}}" | jq -r '.run.output.transcript'`;
|
|
77
|
+
|
|
78
|
+
/** Start the run first, then stream the file to the id it is watching. */
|
|
79
|
+
const STREAMING = `# No splitting, no ffmpeg — the file goes in ONE request. The only difference
|
|
80
|
+
# from the recipe above is that YOU pick the upload id, so it is already valid
|
|
81
|
+
# when the run starts and the run reads the bytes as they land.
|
|
82
|
+
|
|
83
|
+
# 1. pick an id and start the run on it. Nothing has been uploaded yet.
|
|
84
|
+
ID=$(openssl rand -hex 16)
|
|
85
|
+
RUN=$(curl -s -X POST "$AGENT/workflows/runs" \
|
|
86
|
+
-H 'content-type: application/json' \
|
|
87
|
+
-d "{\\"workflow\\":\\"transcribeStream\\",
|
|
88
|
+
\\"input\\":{\\"recording\\":\\"$ID\\"}}" | jq -r .runId)
|
|
89
|
+
|
|
90
|
+
# 2. PUT the whole file. The upload record exists from the first byte with
|
|
91
|
+
# complete:false, and its size grows — which is what the run polls.
|
|
92
|
+
curl -s -X PUT "$AGENT/workflows/uploads/$ID?name=standup.wav" \
|
|
93
|
+
-H 'content-type: audio/wav' --data-binary @standup.wav | jq -c '{size, complete}'
|
|
94
|
+
|
|
95
|
+
# 3. wake it. The run sleeps between polls, so without this it notices the file
|
|
96
|
+
# is finished up to one poll interval late — every time.
|
|
97
|
+
curl -s -X POST "$AGENT/workflows/runs/$RUN/wake" > /dev/null
|
|
98
|
+
|
|
99
|
+
# 4. collect it whenever — a run id is the whole handle.
|
|
100
|
+
curl -s "$AGENT/workflows/runs/$RUN?wait=60000" | jq -r '.run.output.transcript'
|
|
101
|
+
|
|
102
|
+
# While it runs, from any other shell:
|
|
103
|
+
# curl -s "$AGENT/workflows/uploads/$ID/info" # how much has arrived
|
|
104
|
+
# curl -sN "$AGENT/workflows/runs/$RUN/stream" # what the run is saying`;
|
|
105
|
+
|
|
106
|
+
/** Hand the whole thing to the async API. */
|
|
107
|
+
const BATCH = `# The same two requests as the first recipe — only the workflow name differs.
|
|
108
|
+
# No cutting happens anywhere: the run uploads your file to the async API,
|
|
109
|
+
# polls the job, and reads the text. It also accepts mp3 and m4a, which the
|
|
110
|
+
# two sync flows refuse.
|
|
111
|
+
ID=$(curl -s -X POST "$AGENT/workflows/uploads?name=standup.m4a" \
|
|
112
|
+
-H 'content-type: audio/mp4' --data-binary @standup.m4a | jq -r .id)
|
|
113
|
+
|
|
114
|
+
# No \`wait\` here: an async job takes minutes, well past the 60s ceiling a
|
|
115
|
+
# synchronous read can hold. Start it, then follow the run.
|
|
116
|
+
RUN=$(curl -s -X POST "$AGENT/workflows/runs" \
|
|
117
|
+
-H 'content-type: application/json' \
|
|
118
|
+
-d "{\\"workflow\\":\\"transcribeBatch\\",
|
|
119
|
+
\\"input\\":{\\"recording\\":\\"$ID\\"}}" | jq -r .runId)
|
|
120
|
+
|
|
121
|
+
curl -sN "$AGENT/workflows/runs/$RUN/events" # status, as it changes
|
|
122
|
+
curl -s "$AGENT/workflows/runs/$RUN" | jq -r '.output.transcript // .status'`;
|
|
123
|
+
|
|
124
|
+
/** Every route this app answers, and what each is for. */
|
|
125
|
+
const ROUTES: readonly { route: string; does: string }[] = [
|
|
126
|
+
{ route: "GET /workflows", does: "the three workflows and their input schemas" },
|
|
127
|
+
{ route: "POST /workflows/runs", does: "start a run · body names workflow and input" },
|
|
128
|
+
{ route: "GET /workflows/runs", does: "runs so far · filter by workflow, key, limit" },
|
|
129
|
+
{ route: "GET /workflows/runs/:id", does: "one run · add wait=<ms> to block on it" },
|
|
130
|
+
{ route: "GET /workflows/runs/:id/events", does: "SSE, status transitions" },
|
|
131
|
+
{ route: "GET /workflows/runs/:id/stream", does: "SSE, what the run has written" },
|
|
132
|
+
{ route: "POST /workflows/runs/:id/wake", does: "end a pending sleep early" },
|
|
133
|
+
{ route: "DELETE /workflows/runs/:id", does: "cancel it" },
|
|
134
|
+
{ route: "POST /workflows/uploads", does: "store a file, id minted by the store" },
|
|
135
|
+
{
|
|
136
|
+
route: "PUT /workflows/uploads/:id",
|
|
137
|
+
does: "store a file under YOUR id, readable as it arrives",
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
route: "POST /workflows/uploads/:id/parts",
|
|
141
|
+
does: "declare an upload its parts fill in · ?total=<bytes>",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
route: "PUT /workflows/uploads/:id/parts",
|
|
145
|
+
does: "one window of it · ?offset=<byte>, sent concurrently",
|
|
146
|
+
},
|
|
147
|
+
{ route: "GET /workflows/uploads/:id", does: "read the bytes back · Range honoured" },
|
|
148
|
+
{ route: "GET /workflows/uploads/:id/info", does: "name, bytes stored so far, and complete" },
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The whole API, collapsed by default.
|
|
153
|
+
*
|
|
154
|
+
* `<details>` rather than a state hook: the browser owns disclosure, it is
|
|
155
|
+
* keyboard-accessible and findable by in-page search without anyone wiring either,
|
|
156
|
+
* and a page that renders a transcript should not re-render because somebody
|
|
157
|
+
* expanded a help panel.
|
|
158
|
+
*/
|
|
159
|
+
export function ApiHelp(): ReactNode {
|
|
160
|
+
return (
|
|
161
|
+
<details className="rounded-md border">
|
|
162
|
+
<summary className="cursor-pointer p-4 text-sm font-medium">
|
|
163
|
+
Use the API without this page
|
|
164
|
+
</summary>
|
|
165
|
+
<div className="flex flex-col gap-6 border-t p-4">
|
|
166
|
+
<p className="text-sm opacity-70">
|
|
167
|
+
This page is one caller. Everything it does is plain HTTP on this agent's own origin,
|
|
168
|
+
unauthenticated unless the deployment sets{" "}
|
|
169
|
+
<code className="text-xs">AAI_WORKFLOW_API_TOKEN</code> (then every route wants{" "}
|
|
170
|
+
<code className="text-xs">Authorization: Bearer …</code>). A run id is the whole handle —
|
|
171
|
+
no session, no cookie — so a transcript can be collected from another machine, days later.
|
|
172
|
+
</p>
|
|
173
|
+
<p className="text-sm">
|
|
174
|
+
{/* The API answering for itself: same JSON this form was rendered from. */}
|
|
175
|
+
<a className="underline" href={API} target="_blank" rel="noreferrer">
|
|
176
|
+
{`GET ${API}`}
|
|
177
|
+
</a>{" "}
|
|
178
|
+
<span className="opacity-70">
|
|
179
|
+
— the live listing, including each workflow's input schema. Set{" "}
|
|
180
|
+
<code className="text-xs">AGENT</code> to this page's origin for the recipes below.
|
|
181
|
+
</span>
|
|
182
|
+
</p>
|
|
183
|
+
|
|
184
|
+
<Recipe
|
|
185
|
+
title="Store it, then transcribe"
|
|
186
|
+
note="Sync API. One request per phase, and the upload has to finish before there is a run."
|
|
187
|
+
script={CLASSIC}
|
|
188
|
+
/>
|
|
189
|
+
<Recipe
|
|
190
|
+
title="Transcribe while it uploads"
|
|
191
|
+
note="Sync API, one upload request, no splitting. The run starts on an id you chose and reads the bytes as they land."
|
|
192
|
+
script={STREAMING}
|
|
193
|
+
/>
|
|
194
|
+
<Recipe
|
|
195
|
+
title="Let the provider do it"
|
|
196
|
+
note="Async API. No cutting and no seams, it accepts compressed audio, and the wait belongs to the provider's queue."
|
|
197
|
+
script={BATCH}
|
|
198
|
+
/>
|
|
199
|
+
|
|
200
|
+
<section className="flex flex-col gap-2">
|
|
201
|
+
<h4 className="text-xs font-medium uppercase tracking-[1.2px]">Every route</h4>
|
|
202
|
+
<ul className="flex flex-col gap-1">
|
|
203
|
+
{ROUTES.map((entry) => (
|
|
204
|
+
<li key={entry.route} className="flex flex-col gap-0.5 text-xs sm:flex-row sm:gap-3">
|
|
205
|
+
<code className="shrink-0 sm:w-72">{entry.route}</code>
|
|
206
|
+
<span className="opacity-70">{entry.does}</span>
|
|
207
|
+
</li>
|
|
208
|
+
))}
|
|
209
|
+
</ul>
|
|
210
|
+
</section>
|
|
211
|
+
</div>
|
|
212
|
+
</details>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
@@ -33,6 +33,62 @@
|
|
|
33
33
|
* never travel in one; this page contains no upload code because the SDK owns
|
|
34
34
|
* that.
|
|
35
35
|
*
|
|
36
|
+
* ## Two modes, and the toggle is the template's subject
|
|
37
|
+
*
|
|
38
|
+
* The desk offers both flows the agent declares, and the page is where the
|
|
39
|
+
* difference is legible: pick "while it uploads" and there is a run to watch
|
|
40
|
+
* before any bytes are in, pick "after it uploads" and there is not. They share
|
|
41
|
+
* everything else — one `<Form>`, one picker, one progress log, one transcript —
|
|
42
|
+
* because they take the same input and return the same shape, and the only thing
|
|
43
|
+
* the page chooses is which HOOK submits it.
|
|
44
|
+
*
|
|
45
|
+
* `useWorkflowStream` is the streaming half: it mints the upload id, starts the run
|
|
46
|
+
* on it, sends the file, and wakes the run when the bytes land. `useWorkflowSubmit`
|
|
47
|
+
* is the classic half and is unchanged.
|
|
48
|
+
*
|
|
49
|
+
* Streaming is the DEFAULT because it is faster on any real recording. The classic
|
|
50
|
+
* path stays selectable because it is the shape to read first.
|
|
51
|
+
*
|
|
52
|
+
* ## The third control is about the UPLOAD, not the flow
|
|
53
|
+
*
|
|
54
|
+
* "Split the file across connections" (`parallel`) is orthogonal to the three modes
|
|
55
|
+
* and applies to all of them, which is why it is a checkbox beside the radios
|
|
56
|
+
* rather than a fourth option. A single request moves a file at one connection's
|
|
57
|
+
* throughput, which over any distance is a fraction of the link — so the SDK cuts
|
|
58
|
+
* the file into megabyte-aligned parts and sends four at once. Nothing about the
|
|
59
|
+
* workflow changes: the agent reassembles them, `readUpload` reads the same
|
|
60
|
+
* windows, and the streaming flow still watches the file grow (what it polls is the
|
|
61
|
+
* CONTIGUOUS prefix, which is honest whether one connection or four are filling
|
|
62
|
+
* it).
|
|
63
|
+
*
|
|
64
|
+
* It is selectable rather than always-on for the reason the modes are: this is the
|
|
65
|
+
* template where a reader runs both over the same recording and sees what each
|
|
66
|
+
* costs. It also degrades on its own — a small file, or an agent deployed before
|
|
67
|
+
* the `/parts` routes existed, sends the single request instead — so leaving it on
|
|
68
|
+
* is safe.
|
|
69
|
+
*
|
|
70
|
+
* ## The transcript ARRIVES, rather than appearing at the end
|
|
71
|
+
*
|
|
72
|
+
* A run's `output` exists only when its last segment does, so a page with only
|
|
73
|
+
* that shows a status line for the whole fan-out and then everything at once — on
|
|
74
|
+
* a 97-minute recording, minutes of it. Each segment is emitted the moment it
|
|
75
|
+
* lands (`emit(TRANSCRIPT_STREAM, …)` in `workflows/transcribe.ts`) and
|
|
76
|
+
* `useWorkflowProgress` reads that stream, so the panel renders the transcript
|
|
77
|
+
* growing.
|
|
78
|
+
*
|
|
79
|
+
* Three things make it honest rather than decorative:
|
|
80
|
+
*
|
|
81
|
+
* - **The page stitches with the RUN's own function.** `stitchChunks` is
|
|
82
|
+
* `workflows/stitch.ts`, imported by both, so the live text and the stored one
|
|
83
|
+
* cannot drift into two different transcripts of one recording.
|
|
84
|
+
* - **It is a SEPARATE stream from the progress log.** `report()`'s lines go to
|
|
85
|
+
* the default one, which `<WorkflowProgress>` renders verbatim; objects in
|
|
86
|
+
* there would come out as `[object Object]` between the sentences.
|
|
87
|
+
* - **The finished run wins.** Once `output` exists the panel renders that
|
|
88
|
+
* instead — it is the authoritative text, counted and measured, and a live
|
|
89
|
+
* transcript that stayed on screen beside it would be a second answer with no
|
|
90
|
+
* way to tell which was current.
|
|
91
|
+
*
|
|
36
92
|
* ## Two waits, two bars
|
|
37
93
|
*
|
|
38
94
|
* A recording is the one input big enough that STORING it is itself a wait, and
|
|
@@ -58,14 +114,24 @@ import {
|
|
|
58
114
|
page,
|
|
59
115
|
SubmitButton,
|
|
60
116
|
UploadProgressBar,
|
|
117
|
+
useWorkflowProgress,
|
|
61
118
|
useWorkflowRuns,
|
|
119
|
+
useWorkflowStream,
|
|
62
120
|
useWorkflowSubmit,
|
|
63
121
|
WorkflowFields,
|
|
64
122
|
WorkflowProgress,
|
|
65
123
|
type WorkflowRun,
|
|
66
124
|
} from "@alexkroman1/aai-ui";
|
|
67
|
-
import { useEffect, useState } from "react";
|
|
125
|
+
import { useEffect, useMemo, useState } from "react";
|
|
68
126
|
import type { transcribe } from "./agent.ts";
|
|
127
|
+
import { ApiHelp } from "./api-help.tsx";
|
|
128
|
+
import {
|
|
129
|
+
clock,
|
|
130
|
+
countWords,
|
|
131
|
+
stitchChunks,
|
|
132
|
+
TRANSCRIPT_STREAM,
|
|
133
|
+
type TranscriptChunk,
|
|
134
|
+
} from "./workflows/stitch.ts";
|
|
69
135
|
|
|
70
136
|
/**
|
|
71
137
|
* What a finished run reports.
|
|
@@ -76,15 +142,70 @@ import type { transcribe } from "./agent.ts";
|
|
|
76
142
|
*/
|
|
77
143
|
type Transcript = WorkflowOutputOf<typeof transcribe>;
|
|
78
144
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
145
|
+
/**
|
|
146
|
+
* The three workflows this page drives, keyed by the mode that picks one.
|
|
147
|
+
*
|
|
148
|
+
* The STRINGS matter: a page starts a run by name, so a rename in `agent.ts` is a
|
|
149
|
+
* runtime 400 rather than a compile error. `agent.test.ts` pins all three.
|
|
150
|
+
*/
|
|
151
|
+
const WORKFLOWS = {
|
|
152
|
+
streaming: "transcribeStream",
|
|
153
|
+
classic: "transcribe",
|
|
154
|
+
batch: "transcribeBatch",
|
|
155
|
+
} as const;
|
|
156
|
+
|
|
157
|
+
/** Which flow the form submits through. */
|
|
158
|
+
type Mode = keyof typeof WORKFLOWS;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* What each mode is called, and what picking it changes.
|
|
162
|
+
*
|
|
163
|
+
* The notes are the template's actual subject, so they say what the trade IS rather
|
|
164
|
+
* than which is "best" — the answer depends on the file and the link, and the whole
|
|
165
|
+
* reason all three ship is that a reader can run them over the same recording.
|
|
166
|
+
*/
|
|
167
|
+
const MODES: readonly { mode: Mode; label: string; note: string }[] = [
|
|
168
|
+
{
|
|
169
|
+
mode: "streaming",
|
|
170
|
+
label: "While it uploads",
|
|
171
|
+
note: "Sync API. The run starts first and transcribes each segment as its bytes land, so progress is visible while the file is still moving.",
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
mode: "classic",
|
|
175
|
+
label: "After it uploads",
|
|
176
|
+
note: "Sync API. Store the whole recording, then fan out over it. The simplest shape, and the quickest on a fast link.",
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
mode: "batch",
|
|
180
|
+
label: "Let the provider do it",
|
|
181
|
+
note: "Async API. One job, no cutting, no seams — and it accepts MP3 and M4A, which the two above refuse.",
|
|
182
|
+
},
|
|
183
|
+
];
|
|
81
184
|
|
|
82
185
|
/** Most past runs the history list shows. */
|
|
83
186
|
const HISTORY_LIMIT = 10;
|
|
84
187
|
|
|
85
188
|
function TranscriptionDesk() {
|
|
86
|
-
const
|
|
87
|
-
|
|
189
|
+
const [mode, setMode] = useState<Mode>("streaming");
|
|
190
|
+
// Whether the browser cuts the recording up and sends the pieces at once. One
|
|
191
|
+
// piece of state for all three hooks, because it describes the UPLOAD and every
|
|
192
|
+
// mode has one — see the module doc.
|
|
193
|
+
const [parallel, setParallel] = useState(true);
|
|
194
|
+
// ALL THREE hooks are called every render, because a hook may not be conditional —
|
|
195
|
+
// and that costs nothing here: none of them does anything until its `submit` is
|
|
196
|
+
// called, and `useWorkflowRun` underneath them holds no id until then either.
|
|
197
|
+
const streamed = useWorkflowStream<Transcript>(WORKFLOWS.streaming, { parallel });
|
|
198
|
+
const stored = useWorkflowSubmit<Transcript>(WORKFLOWS.classic, { parallel });
|
|
199
|
+
const batched = useWorkflowSubmit<Transcript>(WORKFLOWS.batch, { parallel });
|
|
200
|
+
// The batch flow uploads the same way the classic one does — the id comes from the
|
|
201
|
+
// store — so it is the SAME hook against a different workflow. Only the streaming
|
|
202
|
+
// mode needs the other one, because only it needs the id before the bytes.
|
|
203
|
+
const active = mode === "streaming" ? streamed : mode === "batch" ? batched : stored;
|
|
204
|
+
const { submit, run, upload, pending, error, reset } = active;
|
|
205
|
+
// History is per WORKFLOW, so the list follows the mode: two flows that produce
|
|
206
|
+
// the same output are still two different things to have run, and merging them
|
|
207
|
+
// would put a run under a heading that cannot explain it.
|
|
208
|
+
const history = useWorkflowRuns<Transcript>(WORKFLOWS[mode], { limit: HISTORY_LIMIT });
|
|
88
209
|
// Which past run the reader is looking at, if any. Its own state rather than
|
|
89
210
|
// a route, because a workflow app is one page and a run id is not a place.
|
|
90
211
|
const [openId, setOpenId] = useState<string | undefined>(undefined);
|
|
@@ -110,10 +231,16 @@ function TranscriptionDesk() {
|
|
|
110
231
|
</p>
|
|
111
232
|
</header>
|
|
112
233
|
|
|
113
|
-
{
|
|
234
|
+
<ModePicker mode={mode} onPick={setMode} disabled={pending} />
|
|
235
|
+
|
|
236
|
+
<UploadPicker parallel={parallel} onPick={setParallel} disabled={pending} />
|
|
237
|
+
|
|
238
|
+
{/* No mapping: the collected values already match the input schema. All three
|
|
239
|
+
workflows declare `recording` as an upload, so the same picker serves every
|
|
240
|
+
mode — how the bytes travel is not a question to ask a person. */}
|
|
114
241
|
<Form onSubmit={(values) => submit(values)} error={error}>
|
|
115
242
|
{/* The NAME, so the schema is fetched here rather than by this page. */}
|
|
116
|
-
<WorkflowFields workflow={
|
|
243
|
+
<WorkflowFields workflow={WORKFLOWS[mode]} />
|
|
117
244
|
{/* Unguarded on purpose: it renders nothing until there are bytes in
|
|
118
245
|
flight, and nothing again once they have landed. */}
|
|
119
246
|
<UploadProgressBar upload={upload} />
|
|
@@ -128,10 +255,101 @@ function TranscriptionDesk() {
|
|
|
128
255
|
openId={openId}
|
|
129
256
|
onOpen={(runId) => setOpenId((current) => (current === runId ? undefined : runId))}
|
|
130
257
|
/>
|
|
258
|
+
|
|
259
|
+
{/* The most useful thing about a workflow app is the least discoverable:
|
|
260
|
+
this page is one caller of an ordinary HTTP API. See `api-help.tsx`. */}
|
|
261
|
+
<ApiHelp />
|
|
131
262
|
</main>
|
|
132
263
|
);
|
|
133
264
|
}
|
|
134
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Which flow submits, as two radios.
|
|
268
|
+
*
|
|
269
|
+
* Radios rather than a toggle or a select, because the choice has a REASON per
|
|
270
|
+
* option and a radio group is the one control with room to show it — the note
|
|
271
|
+
* under each label is what makes this a decision rather than a switch somebody
|
|
272
|
+
* flips to see what happens.
|
|
273
|
+
*
|
|
274
|
+
* Disabled while a submission is in flight: the two hooks hold separate run state,
|
|
275
|
+
* so switching mid-run would swap the panel for the other hook's (empty) one and
|
|
276
|
+
* read as the run having vanished.
|
|
277
|
+
*/
|
|
278
|
+
function ModePicker({
|
|
279
|
+
mode,
|
|
280
|
+
onPick,
|
|
281
|
+
disabled,
|
|
282
|
+
}: {
|
|
283
|
+
mode: Mode;
|
|
284
|
+
onPick: (next: Mode) => void;
|
|
285
|
+
disabled: boolean;
|
|
286
|
+
}) {
|
|
287
|
+
return (
|
|
288
|
+
<fieldset className="flex flex-col gap-3" disabled={disabled}>
|
|
289
|
+
<legend className="text-sm font-medium uppercase tracking-[1.2px]">Transcribe</legend>
|
|
290
|
+
{MODES.map((option) => (
|
|
291
|
+
<label key={option.mode} className="flex items-start gap-3 text-sm">
|
|
292
|
+
<input
|
|
293
|
+
type="radio"
|
|
294
|
+
name="mode"
|
|
295
|
+
className="mt-1"
|
|
296
|
+
value={option.mode}
|
|
297
|
+
checked={mode === option.mode}
|
|
298
|
+
onChange={() => onPick(option.mode)}
|
|
299
|
+
/>
|
|
300
|
+
<span className="flex flex-col gap-0.5">
|
|
301
|
+
<span>{option.label}</span>
|
|
302
|
+
<span className="text-xs opacity-70">{option.note}</span>
|
|
303
|
+
</span>
|
|
304
|
+
</label>
|
|
305
|
+
))}
|
|
306
|
+
</fieldset>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* How the recording travels, as one checkbox.
|
|
312
|
+
*
|
|
313
|
+
* Beside the mode radios rather than among them because it answers a different
|
|
314
|
+
* question — those pick the WORKFLOW, this picks how its input gets there — and
|
|
315
|
+
* every mode is uploading a file either way.
|
|
316
|
+
*
|
|
317
|
+
* Disabled mid-submission for the same reason the radios are: the bytes are
|
|
318
|
+
* already moving, and a control that looks live while changing nothing is worse
|
|
319
|
+
* than one that is plainly unavailable.
|
|
320
|
+
*/
|
|
321
|
+
function UploadPicker({
|
|
322
|
+
parallel,
|
|
323
|
+
onPick,
|
|
324
|
+
disabled,
|
|
325
|
+
}: {
|
|
326
|
+
parallel: boolean;
|
|
327
|
+
onPick: (next: boolean) => void;
|
|
328
|
+
disabled: boolean;
|
|
329
|
+
}) {
|
|
330
|
+
return (
|
|
331
|
+
<fieldset className="flex flex-col gap-3" disabled={disabled}>
|
|
332
|
+
<legend className="text-sm font-medium uppercase tracking-[1.2px]">Upload</legend>
|
|
333
|
+
<label className="flex items-start gap-3 text-sm">
|
|
334
|
+
<input
|
|
335
|
+
type="checkbox"
|
|
336
|
+
className="mt-1"
|
|
337
|
+
name="parallel"
|
|
338
|
+
checked={parallel}
|
|
339
|
+
onChange={(event) => onPick(event.target.checked)}
|
|
340
|
+
/>
|
|
341
|
+
<span className="flex flex-col gap-0.5">
|
|
342
|
+
<span>Split the file across connections</span>
|
|
343
|
+
<span className="text-xs opacity-70">
|
|
344
|
+
Sends the recording as several parts at once instead of in one request, which is most of
|
|
345
|
+
the wait on a long file. Falls back to the single request on a small one.
|
|
346
|
+
</span>
|
|
347
|
+
</span>
|
|
348
|
+
</label>
|
|
349
|
+
</fieldset>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
135
353
|
/**
|
|
136
354
|
* Every recent run, newest first, with its transcript one click away.
|
|
137
355
|
*
|
|
@@ -214,13 +432,19 @@ function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onClear?: ()
|
|
|
214
432
|
finished run up in the panel below shows how it got there. */}
|
|
215
433
|
<WorkflowProgress runId={run.runId} />
|
|
216
434
|
|
|
435
|
+
{/* While it runs, the transcript so far. Unguarded on the run's status
|
|
436
|
+
beyond this: the component renders nothing until a segment has landed,
|
|
437
|
+
and stops the moment there is an `output` to render instead. */}
|
|
438
|
+
{!isTerminal(run) && <LiveTranscript runId={run.runId} />}
|
|
439
|
+
|
|
217
440
|
{/* Discriminated on `status`, so `output` and `error` are reachable
|
|
218
441
|
without a cast — the reason a snapshot is a union rather than a flat
|
|
219
442
|
object with optional fields. */}
|
|
220
443
|
{run.status === "completed" && (
|
|
221
444
|
<>
|
|
222
445
|
<p className="text-xs opacity-60">
|
|
223
|
-
{run.output.segments}
|
|
446
|
+
{run.output.segments} {run.output.segments === 1 ? "segment" : "segments"} ·{" "}
|
|
447
|
+
{duration(run.output.durationMs)} of audio · took {duration(run.output.elapsedMs)} ·{" "}
|
|
224
448
|
{run.output.words} words
|
|
225
449
|
</p>
|
|
226
450
|
<pre className="whitespace-pre-wrap text-sm leading-relaxed">{run.output.transcript}</pre>
|
|
@@ -231,6 +455,66 @@ function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onClear?: ()
|
|
|
231
455
|
);
|
|
232
456
|
}
|
|
233
457
|
|
|
458
|
+
/**
|
|
459
|
+
* The transcript as it arrives, stitched from the segments that have landed.
|
|
460
|
+
*
|
|
461
|
+
* The other half of `<WorkflowProgress>` above it: that one renders what the run
|
|
462
|
+
* SAYS about itself, this one renders what it has produced. Both are the same
|
|
463
|
+
* mechanism — a run's output stream — separated by the namespace, which is what
|
|
464
|
+
* lets this one be typed.
|
|
465
|
+
*
|
|
466
|
+
* It renders NOTHING until a segment lands, so a page can mount it unguarded:
|
|
467
|
+
* before the first chunk there is nothing to say that the progress log is not
|
|
468
|
+
* already saying better.
|
|
469
|
+
*
|
|
470
|
+
* The count is derived from the stitched text rather than summed per chunk,
|
|
471
|
+
* because the seams overlap — adding up the segments would over-count every one
|
|
472
|
+
* of them by a couple of seconds' worth of words.
|
|
473
|
+
*/
|
|
474
|
+
function LiveTranscript({ runId }: { runId: string }) {
|
|
475
|
+
const { progress } = useWorkflowProgress<TranscriptChunk>(runId, {
|
|
476
|
+
namespace: TRANSCRIPT_STREAM,
|
|
477
|
+
});
|
|
478
|
+
// Memoized on the ARRAY, which the hook appends to per read: stitching is a
|
|
479
|
+
// seam search per segment, and a fan-out re-renders this panel on every
|
|
480
|
+
// progress poll whether or not anything arrived.
|
|
481
|
+
const transcript = useMemo(() => stitchChunks(progress), [progress]);
|
|
482
|
+
if (progress.length === 0) return null;
|
|
483
|
+
|
|
484
|
+
// The furthest point reached, not the count: segments land out of order, so
|
|
485
|
+
// "6 segments" says nothing about how much of the recording is covered.
|
|
486
|
+
const covered = Math.max(...progress.map((chunk) => chunk.endMs));
|
|
487
|
+
return (
|
|
488
|
+
<div className="flex flex-col gap-2">
|
|
489
|
+
<p className="text-xs opacity-60">
|
|
490
|
+
{countWords(transcript)} words so far · through {clock(covered)}
|
|
491
|
+
</p>
|
|
492
|
+
<pre className="whitespace-pre-wrap text-sm leading-relaxed opacity-80">{transcript}</pre>
|
|
493
|
+
</div>
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* A duration a person can read.
|
|
499
|
+
*
|
|
500
|
+
* `${Math.round(ms / 1000)}s` was what this printed, and an hour-long recording came
|
|
501
|
+
* out as `3746s` — which a reader asked whether they should parse as 37.46 seconds.
|
|
502
|
+
* A raw second count stops being readable at about ninety of them, and the recordings
|
|
503
|
+
* this desk is FOR are the ones past that.
|
|
504
|
+
*
|
|
505
|
+
* The hours component is omitted when it is zero rather than padded to `0:02:26`, so
|
|
506
|
+
* a two-minute clip reads as `2:26` and only a long one grows a field.
|
|
507
|
+
*/
|
|
508
|
+
function duration(ms: number): string {
|
|
509
|
+
const total = Math.max(0, Math.round(ms / 1000));
|
|
510
|
+
const seconds = String(total % 60).padStart(2, "0");
|
|
511
|
+
const minutes = Math.floor(total / 60) % 60;
|
|
512
|
+
const hours = Math.floor(total / 3600);
|
|
513
|
+
return hours > 0
|
|
514
|
+
? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}`
|
|
515
|
+
: `${minutes}:${seconds}`;
|
|
516
|
+
}
|
|
517
|
+
|
|
234
518
|
/**
|
|
235
519
|
* One line describing where a run has got to.
|
|
236
520
|
*
|