@alexkroman1/aai-cli 9.2.0 → 10.0.1
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/{_bundler-CDuYl5Gb.mjs → _bundler-B31MqYaD.mjs} +7 -33
- package/dist/_bundler.d.ts +3 -41
- package/dist/{_dev-server-BAR8Ya43.mjs → _dev-server-Bo5dSBzQ.mjs} +140 -44
- package/dist/_dev-server.d.ts +1 -8
- package/dist/_dev-vite-config.d.ts +21 -0
- package/dist/{_init-BNotRgV1.mjs → _init-BURbdHNM.mjs} +1 -1
- package/dist/{_slug-api--ZBmIGH-.mjs → _slug-api-CfGQq1Tt.mjs} +1 -1
- package/dist/{_templates-4WcKOjS5.mjs → _templates-0Zr2z6yA.mjs} +37 -2
- package/dist/_templates.d.ts +29 -0
- package/dist/{build-DvDdSwAS.mjs → build-DKC2e9o_.mjs} +1 -2
- package/dist/cli.mjs +46 -22
- package/dist/{delete-8pjH8n8n.mjs → delete-X_CRgCf6.mjs} +1 -1
- package/dist/{deploy-CjU3e-3P.mjs → deploy-CchW5pmw.mjs} +2 -2
- package/dist/{dev-dFzdvwa7.mjs → dev-BfVl1uBA.mjs} +1 -1
- package/dist/{init-DK7GpIF9.mjs → init-BRfoc2EK.mjs} +2 -2
- package/dist/{logs-D2Yhqe2w.mjs → logs-hWnJ66Bl.mjs} +2 -2
- package/dist/scaffold/CLAUDE.md +162 -54
- package/dist/scaffold/package.json +4 -6
- package/dist/scaffold/server.mjs +12 -23
- package/dist/{secret-CwgDLpSy.mjs → secret-B-3T5tvr.mjs} +2 -2
- package/dist/{studio-D3-HWIfk.mjs → studio-wVWNLREn.mjs} +4 -3
- package/dist/templates/call-audit/agent.eval.test.ts +1 -1
- package/dist/templates/call-audit/agent.test.ts +80 -8
- package/dist/templates/call-audit/client.tsx +87 -3
- package/dist/templates/call-audit/workflows/audit.ts +52 -32
- package/dist/templates/call-audit/workflows/ingest.ts +15 -15
- package/dist/templates/call-audit/workflows/media.ts +1 -1
- package/dist/templates/call-audit/workflows/summarize.ts +0 -4
- package/dist/templates/link-digest/agent.eval.test.ts +4 -2
- package/dist/templates/link-digest/agent.test.ts +25 -5
- package/dist/templates/link-digest/client.tsx +65 -7
- package/dist/templates/link-digest/workflows/digest.ts +39 -29
- package/dist/templates/podcast-digest/agent.eval.test.ts +5 -5
- package/dist/templates/podcast-digest/agent.test.ts +108 -2
- package/dist/templates/podcast-digest/client.tsx +89 -8
- package/dist/templates/podcast-digest/workflows/digest.ts +67 -48
- package/dist/templates/podcast-digest/workflows/feeds.ts +1 -4
- package/dist/templates/podcast-digest/workflows/slack.ts +5 -6
- package/dist/templates/recap-workflow/agent.eval.test.ts +72 -8
- package/dist/templates/recap-workflow/agent.test.ts +323 -83
- package/dist/templates/recap-workflow/shared.ts +4 -3
- package/dist/templates/recap-workflow/workflows/recap.ts +322 -86
- package/dist/templates/recap-workflow/workflows/tokens.ts +21 -0
- package/dist/templates/redline/agent.eval.test.ts +1 -1
- package/dist/templates/redline/agent.test.ts +1 -1
- package/dist/templates/redline/client.tsx +83 -3
- package/dist/templates/redline/workflows/redline.ts +26 -23
- package/dist/templates/research-workflow/agent.eval.test.ts +9 -8
- package/dist/templates/research-workflow/agent.test.ts +32 -6
- package/dist/templates/research-workflow/agent.ts +4 -4
- package/dist/templates/research-workflow/shared.ts +4 -3
- package/dist/templates/research-workflow/workflows/prompts.ts +1 -1
- package/dist/templates/research-workflow/workflows/research.ts +35 -38
- package/dist/templates/spoken-summary/agent.eval.test.ts +3 -3
- package/dist/templates/spoken-summary/agent.test.ts +10 -8
- package/dist/templates/spoken-summary/client.tsx +72 -3
- package/dist/templates/spoken-summary/workflows/summarize.ts +25 -21
- package/dist/templates/spoken-summary/workflows/transcribe.ts +15 -18
- package/dist/templates/transcription-workflow/agent.eval.test.ts +1 -1
- package/dist/templates/transcription-workflow/agent.test.ts +626 -14
- package/dist/templates/transcription-workflow/agent.ts +6 -4
- package/dist/templates/transcription-workflow/client.tsx +106 -308
- package/dist/templates/transcription-workflow/recover.test.ts +141 -0
- package/dist/templates/transcription-workflow/recover.ts +130 -0
- package/dist/templates/transcription-workflow/run-panel.tsx +203 -0
- package/dist/templates/transcription-workflow/total-latency.tsx +144 -0
- package/dist/templates/transcription-workflow/workflows/batch.ts +21 -24
- package/dist/templates/transcription-workflow/workflows/downsample.ts +273 -0
- package/dist/templates/transcription-workflow/workflows/normalize.ts +17 -40
- package/dist/templates/transcription-workflow/workflows/stream.ts +177 -24
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +69 -32
- package/dist/templates/transcription-workflow/workflows/wav.ts +15 -4
- package/dist/worker-bundler.d.ts +0 -14
- package/dist/worker-bundler.mjs +149 -1
- package/dist/{workflow-BflATMmU.mjs → workflow-BBu5oEDw.mjs} +84 -10
- package/dist/workflow.d.ts +1 -0
- package/package.json +4 -6
- package/dist/_workflow-scan.d.ts +0 -108
- package/dist/worker-bundler-CCVEDjm1.mjs +0 -650
- package/dist/workflow-bundler.d.ts +0 -138
- package/dist/{_studio-CKrsixd0.mjs → _studio-B1waXMuR.mjs} +1 -1
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
* `aai secret put ASSEMBLYAI_API_KEY` once deployed. `requiredEnv` below is
|
|
18
18
|
* what makes a deploy check for it rather than letting the first run find out.
|
|
19
19
|
* A step reads it with `requireStepEnv`; see `@alexkroman1/aai/step`.
|
|
20
|
-
* - **
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* - **No database.** This used to ask for a `DATABASE_URL` and no longer does:
|
|
21
|
+
* an upload's record is a platform row and its bytes are platform storage, so
|
|
22
|
+
* the form below and every step that reads back what it stored work on a
|
|
23
|
+
* deployed app with nothing provisioned — and under `aai dev` they go in the
|
|
24
|
+
* project's `.workflow-data` directory beside the runs. Bring a database only
|
|
25
|
+
* for data of your OWN that has to outlive a run.
|
|
24
26
|
* - **ffmpeg, under `aai dev` only.** A deployed guest's image installs it; on a
|
|
25
27
|
* laptop it is whatever is on `PATH` (or `AAI_FFMPEG_PATH`). The `transcribe`
|
|
26
28
|
* flow needs it for anything that is not already a linear-PCM WAV, because the
|
|
@@ -141,45 +141,74 @@
|
|
|
141
141
|
* either — `output.elapsedMs` is the RUN's own wall clock, so it starts after the
|
|
142
142
|
* bytes are stored in two of the three modes and misses the whole upload, which is
|
|
143
143
|
* most of the wait on a long file over a slow link. Only the browser holds both
|
|
144
|
-
* ends, so `
|
|
145
|
-
* across the upload and the run alike, and
|
|
144
|
+
* ends, so `total-latency.tsx` is a stopwatch: `useTotalLatency` is started by
|
|
145
|
+
* the submit, ticks across the upload and the run alike, and freezes the moment
|
|
146
|
+
* the run settles.
|
|
146
147
|
*
|
|
147
148
|
* `<TotalLatency>` also prints the SPLIT once the run reports its own elapsed —
|
|
148
149
|
* before the run and inside it — because the two numbers on screen otherwise
|
|
149
150
|
* disagree with no way to see why, and their difference is exactly what picking a
|
|
150
151
|
* mode or unchecking `parallel` moves.
|
|
152
|
+
*
|
|
153
|
+
* ## A reload keeps two of the three runs, and the third CANNOT be kept
|
|
154
|
+
*
|
|
155
|
+
* The run id lives in React state, so a refresh loses it while the fan-out
|
|
156
|
+
* carries on. `key` is the handle that survives that and `recover: true` is what
|
|
157
|
+
* reads it back — and here it is a decision PER MODE rather than per page:
|
|
158
|
+
*
|
|
159
|
+
* - **"After it uploads"** and **"Let the provider do it"** recover. Their input
|
|
160
|
+
* names a recording that is already stored, so a later load adopting the run
|
|
161
|
+
* is adopting something complete: the transcript arrives, the progress log
|
|
162
|
+
* replays, and nobody is asked to send a 600 MB file a second time.
|
|
163
|
+
* - **"While it uploads" does not, and the hook REFUSES the option rather than
|
|
164
|
+
* ignoring it.** That run's input names an upload id this page load minted and
|
|
165
|
+
* is still filling, so a later load could only adopt a run waiting for bytes
|
|
166
|
+
* nobody is sending — and it is worse than useless: `workflows/stream.ts`
|
|
167
|
+
* fails a run whose upload stops growing (`MAX_IDLE_POLLS`), so the reload
|
|
168
|
+
* that "recovered" it would be watching it die. Streaming runs are still in
|
|
169
|
+
* Previous runs below, which is where a run this page cannot hold belongs.
|
|
170
|
+
*
|
|
171
|
+
* The MODE is remembered too, and that is not decoration: without it a reload
|
|
172
|
+
* opens on the default flow while the recovered run sits behind a radio nobody
|
|
173
|
+
* pressed, so the reader sees an empty form and starts a second run — the exact
|
|
174
|
+
* thing the key exists to prevent. The KEY is `useRunKey()`, which owns the
|
|
175
|
+
* minting, the storage and the argument for the key being opaque rather than a
|
|
176
|
+
* `?key=` parameter; `recover.ts` owns the mode, which is this page's own
|
|
177
|
+
* concept, and the validation on the way back out of storage that turning a
|
|
178
|
+
* stored string into a workflow name obliges.
|
|
179
|
+
*
|
|
180
|
+
* Two smaller consequences worth knowing. Both recovering hooks look up on
|
|
181
|
+
* mount, so a load costs two `find` requests on a page that was already reading
|
|
182
|
+
* a run listing — cheap, and the alternative (arming the lookup when a mode is
|
|
183
|
+
* picked) would re-adopt a run the reader had just cleared, because the lookup
|
|
184
|
+
* is deliberately a mount-time act. And `<TotalLatency>` shows nothing for a
|
|
185
|
+
* recovered run: the stopwatch is a browser clock and the browser it was
|
|
186
|
+
* running on is gone, which is more honest than a total measured from the reload.
|
|
151
187
|
*/
|
|
152
188
|
|
|
153
189
|
import "@alexkroman1/aai-ui/styles.css";
|
|
154
|
-
import { countWords, formatDuration, plural } from "@alexkroman1/aai/utils";
|
|
155
|
-
import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
|
|
156
190
|
import {
|
|
157
191
|
Form,
|
|
158
192
|
isTerminal,
|
|
159
193
|
page,
|
|
160
194
|
SubmitButton,
|
|
161
195
|
UploadProgressBar,
|
|
162
|
-
|
|
196
|
+
useRunKey,
|
|
163
197
|
useWorkflowRuns,
|
|
164
198
|
useWorkflowStream,
|
|
165
199
|
useWorkflowSubmit,
|
|
166
|
-
WORKFLOW_STATUS_LABELS,
|
|
167
200
|
WorkflowFields,
|
|
168
|
-
WorkflowProgress,
|
|
169
|
-
type WorkflowRun,
|
|
170
201
|
} from "@alexkroman1/aai-ui";
|
|
171
|
-
import {
|
|
202
|
+
import { useEffect, useState } from "react";
|
|
172
203
|
import type { transcribe } from "./agent.ts";
|
|
173
|
-
import {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
*/
|
|
182
|
-
type Transcript = WorkflowOutputOf<typeof transcribe>;
|
|
204
|
+
import { pendingNote, recalledMode, rememberMode } from "./recover.ts";
|
|
205
|
+
// The readouts — one run in flight, and every one before it. Their own module
|
|
206
|
+
// because they are the same whichever of the three hooks produced the run; this
|
|
207
|
+
// file owns the page's shape.
|
|
208
|
+
import { HISTORY_LIMIT, History, RunPanel, type Transcript } from "./run-panel.tsx";
|
|
209
|
+
// The stopwatch and the section that prints it. See its module doc for why the
|
|
210
|
+
// one number a reader wants can only be measured here.
|
|
211
|
+
import { TotalLatency, useTotalLatency } from "./total-latency.tsx";
|
|
183
212
|
|
|
184
213
|
/**
|
|
185
214
|
* The three workflows this page drives, keyed by the mode that picks one.
|
|
@@ -207,7 +236,7 @@ const MODES: readonly { mode: Mode; label: string; note: string }[] = [
|
|
|
207
236
|
{
|
|
208
237
|
mode: "streaming",
|
|
209
238
|
label: "While it uploads",
|
|
210
|
-
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.",
|
|
239
|
+
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 — but the run is reading the file from this page, so it cannot survive a reload.",
|
|
211
240
|
},
|
|
212
241
|
{
|
|
213
242
|
mode: "classic",
|
|
@@ -221,147 +250,51 @@ const MODES: readonly { mode: Mode; label: string; note: string }[] = [
|
|
|
221
250
|
},
|
|
222
251
|
];
|
|
223
252
|
|
|
224
|
-
/** Most past runs the history list shows. */
|
|
225
|
-
const HISTORY_LIMIT = 10;
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* How often the running stopwatch re-renders.
|
|
229
|
-
*
|
|
230
|
-
* Under a second, so the displayed seconds turn over promptly rather than up to a
|
|
231
|
-
* second late; nothing reads this value, since the elapsed time is measured from
|
|
232
|
-
* the clock at render (see {@link useTotalLatency}).
|
|
233
|
-
*/
|
|
234
|
-
const STOPWATCH_TICK_MS = 250;
|
|
235
|
-
|
|
236
|
-
/** What {@link useTotalLatency} reports. */
|
|
237
|
-
type TotalLatency = {
|
|
238
|
-
/**
|
|
239
|
-
* Milliseconds since the submit — ticking while the submission is in flight,
|
|
240
|
-
* frozen at the finish, and undefined before the first one.
|
|
241
|
-
*/
|
|
242
|
-
elapsedMs: number | undefined;
|
|
243
|
-
/** Whether the clock is still running, which is what makes the label honest. */
|
|
244
|
-
running: boolean;
|
|
245
|
-
/** Start (or restart) the clock. Called from the form's own submit handler. */
|
|
246
|
-
start: () => void;
|
|
247
|
-
/** Drop it, for a panel that no longer describes the submission it timed. */
|
|
248
|
-
clear: () => void;
|
|
249
|
-
};
|
|
250
|
-
|
|
251
253
|
/**
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
* `inFlight` is the submission's own `pending` — true from `submit()` until the run
|
|
255
|
-
* reaches a terminal status — so the clock covers the upload, the run, and the
|
|
256
|
-
* gap between them, which is the whole of what a reader waits for and is the one
|
|
257
|
-
* measurement no server-side number can make.
|
|
254
|
+
* Just the mode names, for the recall to check a stored value against.
|
|
258
255
|
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
* from the clock at render, so a tick the tab throttled or dropped cannot make
|
|
263
|
-
* the number lag behind real time.
|
|
264
|
-
* - **`performance.now()`, not `Date.now()`.** It is monotonic, so a clock
|
|
265
|
-
* correction (NTP, a laptop waking up) cannot make a transcription look
|
|
266
|
-
* instant — or negative.
|
|
267
|
-
*/
|
|
268
|
-
function useTotalLatency(inFlight: boolean): TotalLatency {
|
|
269
|
-
const [startedAt, setStartedAt] = useState<number | undefined>(undefined);
|
|
270
|
-
const [frozenMs, setFrozenMs] = useState<number | undefined>(undefined);
|
|
271
|
-
// Re-render trigger only — see the doc above.
|
|
272
|
-
const [, tick] = useState(0);
|
|
273
|
-
// Whether `inFlight` has been seen true since the last `start()`. Without it,
|
|
274
|
-
// a start that lands one render before the submission reports itself in flight
|
|
275
|
-
// would freeze the clock at zero instead of running it.
|
|
276
|
-
const began = useRef(false);
|
|
277
|
-
|
|
278
|
-
useEffect(() => {
|
|
279
|
-
if (startedAt === undefined || frozenMs !== undefined) return;
|
|
280
|
-
if (inFlight) {
|
|
281
|
-
began.current = true;
|
|
282
|
-
const id = setInterval(() => tick((n) => n + 1), STOPWATCH_TICK_MS);
|
|
283
|
-
return () => clearInterval(id);
|
|
284
|
-
}
|
|
285
|
-
// Measured here rather than at render, so the frozen number is the one at the
|
|
286
|
-
// moment the run settled rather than whenever this page next drew.
|
|
287
|
-
if (began.current) setFrozenMs(performance.now() - startedAt);
|
|
288
|
-
}, [startedAt, frozenMs, inFlight]);
|
|
289
|
-
|
|
290
|
-
const start = useCallback(() => {
|
|
291
|
-
began.current = false;
|
|
292
|
-
setFrozenMs(undefined);
|
|
293
|
-
setStartedAt(performance.now());
|
|
294
|
-
}, []);
|
|
295
|
-
|
|
296
|
-
const clear = useCallback(() => {
|
|
297
|
-
began.current = false;
|
|
298
|
-
setStartedAt(undefined);
|
|
299
|
-
setFrozenMs(undefined);
|
|
300
|
-
}, []);
|
|
301
|
-
|
|
302
|
-
return {
|
|
303
|
-
elapsedMs: frozenMs ?? (startedAt === undefined ? undefined : performance.now() - startedAt),
|
|
304
|
-
running: startedAt !== undefined && frozenMs === undefined,
|
|
305
|
-
start,
|
|
306
|
-
clear,
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* The one number the two bars cannot give: click to transcript.
|
|
312
|
-
*
|
|
313
|
-
* Rendered above the run panel rather than inside it, because the stretch it
|
|
314
|
-
* covers starts before there IS a run — in two of the three modes the run does
|
|
315
|
-
* not exist until the upload finishes, so a clock living in the panel would
|
|
316
|
-
* appear only after the wait it is supposed to be timing.
|
|
317
|
-
*
|
|
318
|
-
* `runMs` is the run's own elapsed, once it reports one. The remainder is
|
|
319
|
-
* everything the run could not see: storing the file (or, in streaming mode,
|
|
320
|
-
* minting the upload id), the `POST` that starts the run, and the poll that
|
|
321
|
-
* notices it finished. Clamped at zero, because the two numbers come from two
|
|
322
|
-
* different clocks on two different machines and a few milliseconds the wrong way
|
|
323
|
-
* would otherwise print a negative.
|
|
256
|
+
* Derived from `MODES` rather than written out again: a fourth flow then joins
|
|
257
|
+
* the recall by joining that list, and the two cannot disagree about what a
|
|
258
|
+
* mode is.
|
|
324
259
|
*/
|
|
325
|
-
|
|
326
|
-
elapsedMs,
|
|
327
|
-
running,
|
|
328
|
-
runMs,
|
|
329
|
-
}: {
|
|
330
|
-
elapsedMs: number | undefined;
|
|
331
|
-
running: boolean;
|
|
332
|
-
runMs: number | undefined;
|
|
333
|
-
}) {
|
|
334
|
-
if (elapsedMs === undefined) return null;
|
|
335
|
-
const outside = runMs === undefined ? undefined : Math.max(0, elapsedMs - runMs);
|
|
336
|
-
return (
|
|
337
|
-
<section className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 rounded-md border px-5 py-3">
|
|
338
|
-
<h2 className="text-sm font-medium uppercase tracking-[1.2px]">
|
|
339
|
-
{running ? "Elapsed" : "Total latency"}
|
|
340
|
-
</h2>
|
|
341
|
-
<span className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
|
342
|
-
<span className="text-sm tabular-nums">{formatDuration(elapsedMs)}</span>
|
|
343
|
-
{runMs !== undefined && outside !== undefined && (
|
|
344
|
-
<span className="text-xs tabular-nums opacity-60">
|
|
345
|
-
{formatDuration(outside)} before the run · {formatDuration(runMs)} inside it
|
|
346
|
-
</span>
|
|
347
|
-
)}
|
|
348
|
-
</span>
|
|
349
|
-
</section>
|
|
350
|
-
);
|
|
351
|
-
}
|
|
260
|
+
const MODE_NAMES: readonly Mode[] = MODES.map((option) => option.mode);
|
|
352
261
|
|
|
353
262
|
function TranscriptionDesk() {
|
|
354
|
-
|
|
263
|
+
// The mode the last submission used, so a recovered run is in front of the
|
|
264
|
+
// reader rather than behind a radio nobody pressed. Lazy, and validated on the
|
|
265
|
+
// way out of storage — see `recalledMode`.
|
|
266
|
+
const [mode, setMode] = useState<Mode>(() => recalledMode(MODE_NAMES, "streaming"));
|
|
355
267
|
// Whether the browser cuts the recording up and sends the pieces at once. One
|
|
356
268
|
// piece of state for all three hooks, because it describes the UPLOAD and every
|
|
357
269
|
// mode has one — see the module doc.
|
|
358
270
|
const [parallel, setParallel] = useState(true);
|
|
271
|
+
// This tab's handle on its own runs — minted once and remembered, which is
|
|
272
|
+
// what a later load produces to find the run again.
|
|
273
|
+
const key = useRunKey();
|
|
274
|
+
// Did THIS load press Transcribe? A reload cannot have, and it is the only way
|
|
275
|
+
// the page can tell "working on what you just sent" from "picking up where you
|
|
276
|
+
// left off" — the hooks report the run, not who asked for it.
|
|
277
|
+
const [startedHere, setStartedHere] = useState(false);
|
|
359
278
|
// ALL THREE hooks are called every render, because a hook may not be conditional —
|
|
360
279
|
// and that costs nothing here: none of them does anything until its `submit` is
|
|
361
280
|
// called, and `useWorkflowRun` underneath them holds no id until then either.
|
|
281
|
+
//
|
|
282
|
+
// `recover` is a constant `true` on the two that take it rather than
|
|
283
|
+
// `mode === …`: the lookup is a MOUNT-time act, so arming it when a mode is
|
|
284
|
+
// picked would re-adopt a run the reader had just cleared. The streaming hook
|
|
285
|
+
// takes neither half — it refuses `recover` by type, and recording a key it
|
|
286
|
+
// will never read back would be config nothing uses.
|
|
362
287
|
const streamed = useWorkflowStream<typeof transcribe>(WORKFLOWS.streaming, { parallel });
|
|
363
|
-
const stored = useWorkflowSubmit<typeof transcribe>(WORKFLOWS.classic, {
|
|
364
|
-
|
|
288
|
+
const stored = useWorkflowSubmit<typeof transcribe>(WORKFLOWS.classic, {
|
|
289
|
+
parallel,
|
|
290
|
+
key,
|
|
291
|
+
recover: true,
|
|
292
|
+
});
|
|
293
|
+
const batched = useWorkflowSubmit<typeof transcribe>(WORKFLOWS.batch, {
|
|
294
|
+
parallel,
|
|
295
|
+
key,
|
|
296
|
+
recover: true,
|
|
297
|
+
});
|
|
365
298
|
// The batch flow uploads the same way the classic one does — the id comes from the
|
|
366
299
|
// store — so it is the SAME hook against a different workflow. Only the streaming
|
|
367
300
|
// mode needs the other one, because only it needs the id before the bytes.
|
|
@@ -394,7 +327,8 @@ function TranscriptionDesk() {
|
|
|
394
327
|
<h1 className="text-2xl font-medium">Transcription Desk</h1>
|
|
395
328
|
<p className="text-sm opacity-70">
|
|
396
329
|
Upload a WAV recording. It is split into chunks, transcribed chunk by chunk, and stitched
|
|
397
|
-
back together by a durable workflow —
|
|
330
|
+
back together by a durable workflow — which outlives this page, and which two of the three
|
|
331
|
+
flows below can pick up again after a reload.
|
|
398
332
|
</p>
|
|
399
333
|
</header>
|
|
400
334
|
|
|
@@ -422,6 +356,11 @@ function TranscriptionDesk() {
|
|
|
422
356
|
<Form
|
|
423
357
|
onSubmit={(values) => {
|
|
424
358
|
total.start();
|
|
359
|
+
setStartedHere(true);
|
|
360
|
+
// Written at SUBMIT rather than on the radio, so the remembered mode
|
|
361
|
+
// is always the mode a run exists under — which is the only thing the
|
|
362
|
+
// next load can use it for.
|
|
363
|
+
rememberMode(mode);
|
|
425
364
|
return submitForm(values);
|
|
426
365
|
}}
|
|
427
366
|
error={error}
|
|
@@ -442,10 +381,28 @@ function TranscriptionDesk() {
|
|
|
442
381
|
runMs={run?.status === "completed" ? run.output.elapsedMs : undefined}
|
|
443
382
|
/>
|
|
444
383
|
|
|
384
|
+
{/* One sentence about the wait, and the only place the three modes differ
|
|
385
|
+
in what a reader may DO: `pending` is also true on a reload while the
|
|
386
|
+
run is being looked up by key, which is the stretch where an empty form
|
|
387
|
+
would invite a second upload of the same recording. */}
|
|
388
|
+
{pending && (
|
|
389
|
+
<p className="text-sm opacity-70">
|
|
390
|
+
{pendingNote({
|
|
391
|
+
recoverable: mode !== "streaming",
|
|
392
|
+
startedHere,
|
|
393
|
+
found: run !== undefined,
|
|
394
|
+
})}
|
|
395
|
+
</p>
|
|
396
|
+
)}
|
|
397
|
+
|
|
445
398
|
{run && (
|
|
446
399
|
<RunPanel
|
|
447
400
|
run={run}
|
|
448
401
|
onClear={() => {
|
|
402
|
+
// A recovered run is dismissed as deliberately as one this load
|
|
403
|
+
// started: the lookup is a mount-time act, so `reset()` is not
|
|
404
|
+
// undone by a second one and Clear really does clear.
|
|
405
|
+
setStartedHere(false);
|
|
449
406
|
reset();
|
|
450
407
|
total.clear();
|
|
451
408
|
}}
|
|
@@ -550,163 +507,4 @@ function UploadPicker({
|
|
|
550
507
|
);
|
|
551
508
|
}
|
|
552
509
|
|
|
553
|
-
/**
|
|
554
|
-
* Every recent run, newest first, with its transcript one click away.
|
|
555
|
-
*
|
|
556
|
-
* This is what a durable workflow with an HTTP API is FOR, and the page used to
|
|
557
|
-
* squander it: a run id is the whole handle — no session, no cookie — so
|
|
558
|
-
* `GET /workflows/runs` can answer "what has this desk transcribed" for any tab,
|
|
559
|
-
* any machine, days later. What stood here instead was a text box asking the
|
|
560
|
-
* reader to paste an id they would have had to write down, which is the same
|
|
561
|
-
* information behind a worse door.
|
|
562
|
-
*/
|
|
563
|
-
function History({
|
|
564
|
-
runs,
|
|
565
|
-
error,
|
|
566
|
-
openId,
|
|
567
|
-
onOpen,
|
|
568
|
-
}: {
|
|
569
|
-
runs: WorkflowRun<Transcript>[];
|
|
570
|
-
error: string | undefined;
|
|
571
|
-
openId: string | undefined;
|
|
572
|
-
onOpen: (runId: string) => void;
|
|
573
|
-
}) {
|
|
574
|
-
return (
|
|
575
|
-
<section className="flex flex-col gap-3 border-t pt-6">
|
|
576
|
-
<h2 className="text-sm font-medium uppercase tracking-[1.2px]">Previous runs</h2>
|
|
577
|
-
{error !== undefined && <p className="text-sm text-red-600">{error}</p>}
|
|
578
|
-
{runs.length === 0 && error === undefined && (
|
|
579
|
-
<p className="text-sm opacity-60">Nothing transcribed yet.</p>
|
|
580
|
-
)}
|
|
581
|
-
<ul className="flex flex-col">
|
|
582
|
-
{runs.map((entry) => (
|
|
583
|
-
<li key={entry.runId} className="border-b last:border-b-0">
|
|
584
|
-
<button
|
|
585
|
-
type="button"
|
|
586
|
-
onClick={() => onOpen(entry.runId)}
|
|
587
|
-
className="flex w-full items-baseline justify-between gap-4 py-2 text-left text-sm"
|
|
588
|
-
>
|
|
589
|
-
<span className="truncate">{title(entry)}</span>
|
|
590
|
-
<span className="shrink-0 text-xs opacity-60">{STATUS_LINE[entry.status]}</span>
|
|
591
|
-
</button>
|
|
592
|
-
{openId === entry.runId && <RunPanel run={entry} />}
|
|
593
|
-
</li>
|
|
594
|
-
))}
|
|
595
|
-
</ul>
|
|
596
|
-
</section>
|
|
597
|
-
);
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
/**
|
|
601
|
-
* One line naming a past run.
|
|
602
|
-
*
|
|
603
|
-
* The FILE where there is one — `mergeTranscript` puts the recording's own name
|
|
604
|
-
* in the output for exactly this — falling back to the id, which is all a run
|
|
605
|
-
* that failed before it read the upload ever had.
|
|
606
|
-
*/
|
|
607
|
-
function title(run: WorkflowRun<Transcript>): string {
|
|
608
|
-
if (run.status === "completed") return run.output.source;
|
|
609
|
-
return run.runId;
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
/** The run's status, its narration, and its transcript once there is one. */
|
|
613
|
-
function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onClear?: () => void }) {
|
|
614
|
-
return (
|
|
615
|
-
<section className="flex flex-col gap-3 rounded-md border p-5">
|
|
616
|
-
<div className="flex items-baseline justify-between gap-4">
|
|
617
|
-
<h2 className="text-sm font-medium uppercase tracking-[1.2px]">
|
|
618
|
-
{STATUS_LINE[run.status]}
|
|
619
|
-
</h2>
|
|
620
|
-
{onClear && (
|
|
621
|
-
<button type="button" onClick={onClear} className="text-xs underline opacity-60">
|
|
622
|
-
Clear
|
|
623
|
-
</button>
|
|
624
|
-
)}
|
|
625
|
-
</div>
|
|
626
|
-
|
|
627
|
-
{/* The run's own narration, oldest first — the complement of `STATUS_LINE`
|
|
628
|
-
below, and the reason both exist: the status is `running` for the whole
|
|
629
|
-
fan-out, so a sixty-segment recording and a one-segment recording look
|
|
630
|
-
identical while they run. These lines come from the run itself
|
|
631
|
-
(`report()` in `workflows/transcribe.ts`), and they REPLAY, so looking a
|
|
632
|
-
finished run up in the panel below shows how it got there. */}
|
|
633
|
-
<WorkflowProgress runId={run.runId} />
|
|
634
|
-
|
|
635
|
-
{/* While it runs, the transcript so far. Unguarded on the run's status
|
|
636
|
-
beyond this: the component renders nothing until a segment has landed,
|
|
637
|
-
and stops the moment there is an `output` to render instead. */}
|
|
638
|
-
{!isTerminal(run) && <LiveTranscript runId={run.runId} />}
|
|
639
|
-
|
|
640
|
-
{/* Discriminated on `status`, so `output` and `error` are reachable
|
|
641
|
-
without a cast — the reason a snapshot is a union rather than a flat
|
|
642
|
-
object with optional fields. */}
|
|
643
|
-
{run.status === "completed" && (
|
|
644
|
-
<>
|
|
645
|
-
<p className="text-xs opacity-60">
|
|
646
|
-
{run.output.segments} {plural(run.output.segments, "segment")} ·{" "}
|
|
647
|
-
{formatDuration(run.output.durationMs)} of audio · took{" "}
|
|
648
|
-
{formatDuration(run.output.elapsedMs)} · {run.output.words} words
|
|
649
|
-
</p>
|
|
650
|
-
<pre className="whitespace-pre-wrap text-sm leading-relaxed">{run.output.transcript}</pre>
|
|
651
|
-
</>
|
|
652
|
-
)}
|
|
653
|
-
{run.status === "failed" && <p className="text-red-600">{run.error}</p>}
|
|
654
|
-
</section>
|
|
655
|
-
);
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
/**
|
|
659
|
-
* The transcript as it arrives, stitched from the segments that have landed.
|
|
660
|
-
*
|
|
661
|
-
* The other half of `<WorkflowProgress>` above it: that one renders what the run
|
|
662
|
-
* SAYS about itself, this one renders what it has produced. Both are the same
|
|
663
|
-
* mechanism — a run's output stream — separated by the namespace, which is what
|
|
664
|
-
* lets this one be typed.
|
|
665
|
-
*
|
|
666
|
-
* It renders NOTHING until a segment lands, so a page can mount it unguarded:
|
|
667
|
-
* before the first chunk there is nothing to say that the progress log is not
|
|
668
|
-
* already saying better.
|
|
669
|
-
*
|
|
670
|
-
* The count is derived from the stitched text rather than summed per chunk,
|
|
671
|
-
* because the seams overlap — adding up the segments would over-count every one
|
|
672
|
-
* of them by a couple of seconds' worth of words.
|
|
673
|
-
*/
|
|
674
|
-
function LiveTranscript({ runId }: { runId: string }) {
|
|
675
|
-
const { progress } = useWorkflowProgress<TranscriptChunk>(runId, {
|
|
676
|
-
namespace: TRANSCRIPT_STREAM,
|
|
677
|
-
});
|
|
678
|
-
// Memoized on the ARRAY, which the hook appends to per read: stitching is a
|
|
679
|
-
// seam search per segment, and a fan-out re-renders this panel on every
|
|
680
|
-
// progress poll whether or not anything arrived.
|
|
681
|
-
const transcript = useMemo(() => stitchChunks(progress), [progress]);
|
|
682
|
-
if (progress.length === 0) return null;
|
|
683
|
-
|
|
684
|
-
// The furthest point reached, not the count: segments land out of order, so
|
|
685
|
-
// "6 segments" says nothing about how much of the recording is covered.
|
|
686
|
-
const covered = Math.max(...progress.map((chunk) => chunk.endMs));
|
|
687
|
-
return (
|
|
688
|
-
<div className="flex flex-col gap-2">
|
|
689
|
-
<p className="text-xs opacity-60">
|
|
690
|
-
{countWords(transcript)} words so far · through {formatDuration(covered)}
|
|
691
|
-
</p>
|
|
692
|
-
<pre className="whitespace-pre-wrap text-sm leading-relaxed opacity-80">{transcript}</pre>
|
|
693
|
-
</div>
|
|
694
|
-
);
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
/**
|
|
698
|
-
* One line describing where a run has got to.
|
|
699
|
-
*
|
|
700
|
-
* `WORKFLOW_STATUS_LABELS` is the SDK's neutral map — a `Record` keyed by the
|
|
701
|
-
* status union rather than a switch, so a status added upstream is a compile
|
|
702
|
-
* error in one place every page inherits, and spreading a complete record cannot
|
|
703
|
-
* drop a key. Two of these keys are really this desk's: a page knows what its
|
|
704
|
-
* workflow does and the SDK does not.
|
|
705
|
-
*/
|
|
706
|
-
const STATUS_LINE = {
|
|
707
|
-
...WORKFLOW_STATUS_LABELS,
|
|
708
|
-
running: "Transcribing…",
|
|
709
|
-
completed: "Transcript ready",
|
|
710
|
-
};
|
|
711
|
-
|
|
712
510
|
page({ name: "Transcription Desk", component: TranscriptionDesk });
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* Specs for what a reload remembers — `recover.ts`.
|
|
4
|
+
*
|
|
5
|
+
* This is the testable half of the page's recovery, and the split is worth
|
|
6
|
+
* knowing before adding to either side. What lives HERE is the decisions that
|
|
7
|
+
* are ordinary functions: handing back a mode that may not be a mode any more,
|
|
8
|
+
* and which sentence a reader gets. The KEY is not one of them any more — it is
|
|
9
|
+
* `useRunKey()` (`@alexkroman1/aai-ui`), specced there, over the hook that mints
|
|
10
|
+
* it. What CANNOT live here is the other hook — `useWorkflowSubmit({ key,
|
|
11
|
+
* recover: true })` adopting the key's newest run, once per mount, and never
|
|
12
|
+
* over a run the person has already started — because this package's suites
|
|
13
|
+
* have no DOM (the vitest `include`
|
|
14
|
+
* matches `.test.ts` and not `.test.tsx`, and the scaffold declares no React
|
|
15
|
+
* testing library, so a `client.test.tsx` would be collected by nothing here
|
|
16
|
+
* and would break `aai test` in a scaffolded project). That half is specced in
|
|
17
|
+
* `@alexkroman1/aai-ui`, over the hook itself.
|
|
18
|
+
*
|
|
19
|
+
* Storage is a FAKE rather than jsdom's, for the same reason: `sessionStorage`
|
|
20
|
+
* does not exist in Node, which is also the environment in which every guard in
|
|
21
|
+
* `recover.ts` matters — a page that cannot reach storage still has to render.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
25
|
+
import { pendingNote, recalledMode, rememberMode } from "./recover.ts";
|
|
26
|
+
|
|
27
|
+
/** The modes the page offers, in the page's own order. */
|
|
28
|
+
const MODES = ["streaming", "classic", "batch"] as const;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Just enough `Storage` for the two recall functions, backed by a map a spec
|
|
32
|
+
* can read.
|
|
33
|
+
*
|
|
34
|
+
* `vi.stubGlobal` rather than an injected parameter, because the guarded
|
|
35
|
+
* `globalThis.sessionStorage?.…` access IS the thing under test — a version
|
|
36
|
+
* taking a store would test a shape the template does not ship.
|
|
37
|
+
*/
|
|
38
|
+
function fakeStorage(seed: Record<string, string> = {}) {
|
|
39
|
+
const store = new Map(Object.entries(seed));
|
|
40
|
+
vi.stubGlobal("sessionStorage", {
|
|
41
|
+
getItem: (k: string) => store.get(k) ?? null,
|
|
42
|
+
setItem: (k: string, v: string) => void store.set(k, v),
|
|
43
|
+
});
|
|
44
|
+
return store;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Storage that THROWS on both halves — Safari private mode, a sandboxed frame. */
|
|
48
|
+
function refusingStorage() {
|
|
49
|
+
vi.stubGlobal("sessionStorage", {
|
|
50
|
+
getItem: () => {
|
|
51
|
+
throw new DOMException("denied");
|
|
52
|
+
},
|
|
53
|
+
setItem: () => {
|
|
54
|
+
throw new DOMException("denied");
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
// `restoreMocks` covers `vi.spyOn` and `unstubEnvs` covers `vi.stubEnv`;
|
|
61
|
+
// neither covers a stubbed global, so this is the one teardown these specs
|
|
62
|
+
// owe. Without it the first file to stub storage decides every later one.
|
|
63
|
+
vi.unstubAllGlobals();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("recalledMode", () => {
|
|
67
|
+
test("opens on the mode the last submission used", () => {
|
|
68
|
+
fakeStorage({ "transcription-workflow:mode": "batch" });
|
|
69
|
+
expect(recalledMode(MODES, "streaming")).toBe("batch");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("falls back for a stored value that is not a mode any more", () => {
|
|
73
|
+
// The branch this function exists for. The page turns a mode into a
|
|
74
|
+
// workflow NAME, so an unchecked value here starts a run called `undefined`
|
|
75
|
+
// and answers a 400 nobody typed.
|
|
76
|
+
fakeStorage({ "transcription-workflow:mode": "webhook" });
|
|
77
|
+
expect(recalledMode(MODES, "streaming")).toBe("streaming");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("falls back when nothing has been stored yet", () => {
|
|
81
|
+
fakeStorage();
|
|
82
|
+
expect(recalledMode(MODES, "streaming")).toBe("streaming");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("falls back when storage refuses", () => {
|
|
86
|
+
refusingStorage();
|
|
87
|
+
expect(recalledMode(MODES, "classic")).toBe("classic");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("round-trips what rememberMode wrote", () => {
|
|
91
|
+
fakeStorage();
|
|
92
|
+
rememberMode("classic");
|
|
93
|
+
expect(recalledMode(MODES, "streaming")).toBe("classic");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("rememberMode is silent when storage refuses", () => {
|
|
97
|
+
refusingStorage();
|
|
98
|
+
expect(() => rememberMode("classic")).not.toThrow();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("pendingNote", () => {
|
|
103
|
+
test("tells a streaming reader to keep the tab open, whoever started the run", () => {
|
|
104
|
+
// First and unconditional: that run is reading the file from this page, so
|
|
105
|
+
// a reload does not orphan it, it ends it. A page promising otherwise in
|
|
106
|
+
// the mode it OPENS in would be the worst copy on the desk.
|
|
107
|
+
const started = pendingNote({ recoverable: false, startedHere: true, found: true });
|
|
108
|
+
expect(started).toMatch(/keep this tab open/i);
|
|
109
|
+
expect(pendingNote({ recoverable: false, startedHere: false, found: false })).toBe(started);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("promises the reload back to whoever pressed the button", () => {
|
|
113
|
+
const note = pendingNote({ recoverable: true, startedHere: true, found: true });
|
|
114
|
+
expect(note).toMatch(/reloading is safe/i);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("says it is LOOKING while the lookup is still out", () => {
|
|
118
|
+
const note = pendingNote({ recoverable: true, startedHere: false, found: false });
|
|
119
|
+
expect(note).toMatch(/looking for/i);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("explains a run the reader did not start, and says not to send it again", () => {
|
|
123
|
+
// The line that stops a second 600 MB upload of the same recording, which
|
|
124
|
+
// is what the key is for.
|
|
125
|
+
const note = pendingNote({ recoverable: true, startedHere: false, found: true });
|
|
126
|
+
expect(note).toMatch(/earlier/i);
|
|
127
|
+
expect(note).toMatch(/no need to send it again/i);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("says something different in each of its four situations", () => {
|
|
131
|
+
const notes = [
|
|
132
|
+
pendingNote({ recoverable: false, startedHere: true, found: true }),
|
|
133
|
+
pendingNote({ recoverable: true, startedHere: true, found: true }),
|
|
134
|
+
pendingNote({ recoverable: true, startedHere: false, found: false }),
|
|
135
|
+
pendingNote({ recoverable: true, startedHere: false, found: true }),
|
|
136
|
+
];
|
|
137
|
+
// A branch that duplicates its neighbour's sentence is a branch nobody can
|
|
138
|
+
// see, and the four are the whole of what this page says about the wait.
|
|
139
|
+
expect(new Set(notes).size).toBe(notes.length);
|
|
140
|
+
});
|
|
141
|
+
});
|