@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
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* What a reload has to remember BESIDES the key: which MODE started the run.
|
|
4
|
+
*
|
|
5
|
+
* A `runId` names a run for as long as something holds it, and the page holds it
|
|
6
|
+
* in React state — so a refresh loses it while the fan-out carries on
|
|
7
|
+
* transcribing. `useWorkflowSubmit({ key, recover: true })` is the fix; the key
|
|
8
|
+
* itself is `useRunKey()` (`@alexkroman1/aai-ui`), which owns the minting, the
|
|
9
|
+
* storage and the argument for both. What is left here is the value that is
|
|
10
|
+
* this PAGE's own concept — the mode — plus the sentence the page says while a
|
|
11
|
+
* run it did not start is arriving.
|
|
12
|
+
*
|
|
13
|
+
* It is its own module for two reasons, and only the second is about keeping
|
|
14
|
+
* `client.tsx` down to the page's shape:
|
|
15
|
+
*
|
|
16
|
+
* - **A template's spec is what makes its exemplar code true**, and none of this
|
|
17
|
+
* can be tested from a `client.tsx`. That file ends in a `page()` call and
|
|
18
|
+
* imports a stylesheet, and this package's suites have no DOM at all: the
|
|
19
|
+
* vitest `include` matches `.test.ts` and not `.test.tsx`, and the scaffold
|
|
20
|
+
* declares no React testing library — so a `client.test.tsx` would be
|
|
21
|
+
* collected by nothing here AND would break `aai test` in a scaffolded
|
|
22
|
+
* project. Here the decisions are ordinary functions, so `recover.test.ts`
|
|
23
|
+
* pins them; what only a DOM can show (that the hook adopts the run, once,
|
|
24
|
+
* and never over a submit) is `aai-ui`'s own suite.
|
|
25
|
+
* - The mode recall has a REAL branch in it: what comes back out of storage is
|
|
26
|
+
* an untrusted string, and a page that trusted it would index `WORKFLOWS`
|
|
27
|
+
* with it and start a run by the name `undefined` — a 400 from a value nobody
|
|
28
|
+
* typed.
|
|
29
|
+
*
|
|
30
|
+
* ## The two values keep the same lifetime, and it is not a coincidence
|
|
31
|
+
*
|
|
32
|
+
* The mode lives in `sessionStorage`, which is the store `useRunKey()` defaults
|
|
33
|
+
* to and the same lifetime as the SDK's own upload recall (`useWorkflowSubmit`
|
|
34
|
+
* remembers the id it minted, so picking the same file again sends only the
|
|
35
|
+
* windows that did not land). All three halves of a reload therefore make the
|
|
36
|
+
* same promise: a mode remembered longer than the key that finds the run would
|
|
37
|
+
* open the desk on a flow with nothing behind it.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** Where the mode that last submitted lives between loads. */
|
|
41
|
+
const MODE_STORAGE = "transcription-workflow:mode";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Remember which flow the reader picked, so the next load shows the run it
|
|
45
|
+
* started.
|
|
46
|
+
*
|
|
47
|
+
* Without this the recovery is half-done in a way that reads as broken: the
|
|
48
|
+
* mode resets to the default on a reload, so a recovered classic run sits
|
|
49
|
+
* behind a radio nobody pressed while the page in front of the reader is an
|
|
50
|
+
* empty form — which is the "start a second run" invitation the key exists to
|
|
51
|
+
* remove.
|
|
52
|
+
*
|
|
53
|
+
* @param mode - The mode that is about to submit.
|
|
54
|
+
*/
|
|
55
|
+
export function rememberMode(mode: string): void {
|
|
56
|
+
try {
|
|
57
|
+
globalThis.sessionStorage?.setItem(MODE_STORAGE, mode);
|
|
58
|
+
} catch {
|
|
59
|
+
// A desk that cannot remember its mode still transcribes; it just opens on
|
|
60
|
+
// the default next time.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The mode the last load submitted with, if it is still one of the modes.
|
|
66
|
+
*
|
|
67
|
+
* The validation is the point. Storage hands back a string this page wrote
|
|
68
|
+
* SOME version ago — a renamed mode, a hand-edited value, a key another app on
|
|
69
|
+
* the origin happens to share — and the page turns a mode into a workflow NAME.
|
|
70
|
+
* An unchecked value would start a run called `undefined` and answer a 400
|
|
71
|
+
* nobody can explain, so anything not in `valid` falls back.
|
|
72
|
+
*
|
|
73
|
+
* @param valid - The modes this page offers, which is the page's own list
|
|
74
|
+
* rather than a copy of it.
|
|
75
|
+
* @param fallback - The mode to open on when there is nothing to recall.
|
|
76
|
+
* @returns One of `valid`, always.
|
|
77
|
+
*/
|
|
78
|
+
export function recalledMode<M extends string>(valid: readonly M[], fallback: M): M {
|
|
79
|
+
try {
|
|
80
|
+
const stored = globalThis.sessionStorage?.getItem(MODE_STORAGE);
|
|
81
|
+
// `find` rather than a cast plus `includes`: the narrowing is then the
|
|
82
|
+
// lookup's own, so there is nothing to keep in step by hand.
|
|
83
|
+
return valid.find((mode) => mode === stored) ?? fallback;
|
|
84
|
+
} catch {
|
|
85
|
+
return fallback;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What {@link pendingNote} needs to know about the run in flight. */
|
|
90
|
+
export type PendingNoteInput = {
|
|
91
|
+
/**
|
|
92
|
+
* Whether this flow's run can be picked up after a reload at all.
|
|
93
|
+
*
|
|
94
|
+
* False for the streaming flow, and the sentence has to say so — see
|
|
95
|
+
* {@link pendingNote}.
|
|
96
|
+
*/
|
|
97
|
+
recoverable: boolean;
|
|
98
|
+
/** Whether THIS load pressed Transcribe. A reload cannot have. */
|
|
99
|
+
startedHere: boolean;
|
|
100
|
+
/** Whether a run has arrived yet, which on a reload means the lookup landed. */
|
|
101
|
+
found: boolean;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* What the desk says while something is in flight — four situations, one line
|
|
106
|
+
* each.
|
|
107
|
+
*
|
|
108
|
+
* The one that earns this function is the first: the streaming flow's run reads
|
|
109
|
+
* the recording as this page sends it, so a reload does not orphan that run, it
|
|
110
|
+
* ENDS it — `workflows/stream.ts` gives an upload that stops growing five
|
|
111
|
+
* minutes (`MAX_IDLE_POLLS`) before it calls the uploader gone and fails the
|
|
112
|
+
* run. A page telling a reader they can close the tab would be wrong in the
|
|
113
|
+
* mode this desk opens in.
|
|
114
|
+
*
|
|
115
|
+
* The other three are the ordinary recovery copy, and the reload case gets its
|
|
116
|
+
* own words deliberately: somebody who did not press the button is owed an
|
|
117
|
+
* explanation for a transcript appearing in front of them, and it is the line
|
|
118
|
+
* that stops them sending the recording again.
|
|
119
|
+
*
|
|
120
|
+
* @param input - See {@link PendingNoteInput}.
|
|
121
|
+
* @returns One sentence, always.
|
|
122
|
+
*/
|
|
123
|
+
export function pendingNote(input: PendingNoteInput): string {
|
|
124
|
+
const { recoverable, startedHere, found } = input;
|
|
125
|
+
if (!recoverable)
|
|
126
|
+
return "Keep this tab open — the run is reading the recording as this page sends it, so a reload ends the run.";
|
|
127
|
+
if (startedHere) return "Reloading is safe — this page will find the run again.";
|
|
128
|
+
if (!found) return "Looking for a transcript this tab started earlier…";
|
|
129
|
+
return "Still transcribing a recording this tab sent earlier — no need to send it again.";
|
|
130
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* What a run LOOKS like — one in flight, and every one before it.
|
|
4
|
+
*
|
|
5
|
+
* `client.tsx` owns the page's shape: the form, the three hooks, the mode, and
|
|
6
|
+
* which of them is active. This module owns the readouts, which are the same
|
|
7
|
+
* whichever hook produced the run — a `WorkflowRun<Transcript>` is a
|
|
8
|
+
* `WorkflowRun<Transcript>` whether it arrived from `useWorkflowStream`,
|
|
9
|
+
* `useWorkflowSubmit` or the history listing, which is exactly why `<RunPanel>`
|
|
10
|
+
* serves both the live run and an expanded past one.
|
|
11
|
+
*
|
|
12
|
+
* Three components, in the order a reader meets them:
|
|
13
|
+
*
|
|
14
|
+
* - **`<RunPanel>`** — the status, the narration, and the transcript once there
|
|
15
|
+
* is one.
|
|
16
|
+
* - **`<LiveTranscript>`** — the transcript as it ARRIVES, which is what makes a
|
|
17
|
+
* sixty-segment fan-out watchable.
|
|
18
|
+
* - **`<History>`** — every recent run, newest first.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { countWords, formatDuration, plural } from "@alexkroman1/aai/utils";
|
|
22
|
+
import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
|
|
23
|
+
import {
|
|
24
|
+
isTerminal,
|
|
25
|
+
useWorkflowProgress,
|
|
26
|
+
WORKFLOW_STATUS_LABELS,
|
|
27
|
+
WorkflowProgress,
|
|
28
|
+
type WorkflowRun,
|
|
29
|
+
} from "@alexkroman1/aai-ui";
|
|
30
|
+
import { useMemo } from "react";
|
|
31
|
+
import type { transcribe } from "./agent.ts";
|
|
32
|
+
import { stitchChunks, TRANSCRIPT_STREAM, type TranscriptChunk } from "./workflows/stitch.ts";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What a finished run reports.
|
|
36
|
+
*
|
|
37
|
+
* Derived from the workflow declaration rather than restated — `import type` is
|
|
38
|
+
* erased, so naming `transcribe` here bundles none of the agent, the SDK, or the
|
|
39
|
+
* workflow body into this page.
|
|
40
|
+
*/
|
|
41
|
+
export type Transcript = WorkflowOutputOf<typeof transcribe>;
|
|
42
|
+
|
|
43
|
+
/** Most past runs the history list shows. */
|
|
44
|
+
export const HISTORY_LIMIT = 10;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One line describing where a run has got to.
|
|
48
|
+
*
|
|
49
|
+
* `WORKFLOW_STATUS_LABELS` is the SDK's neutral map — a `Record` keyed by the
|
|
50
|
+
* status union rather than a switch, so a status added upstream is a compile
|
|
51
|
+
* error in one place every page inherits, and spreading a complete record cannot
|
|
52
|
+
* drop a key. Two of these keys are really this desk's: a page knows what its
|
|
53
|
+
* workflow does and the SDK does not.
|
|
54
|
+
*/
|
|
55
|
+
const STATUS_LINE = {
|
|
56
|
+
...WORKFLOW_STATUS_LABELS,
|
|
57
|
+
running: "Transcribing…",
|
|
58
|
+
completed: "Transcript ready",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every recent run, newest first, with its transcript one click away.
|
|
63
|
+
*
|
|
64
|
+
* This is what a durable workflow with an HTTP API is FOR, and the page used to
|
|
65
|
+
* squander it: a run id is the whole handle — no session, no cookie — so
|
|
66
|
+
* `GET /workflows/runs` can answer "what has this desk transcribed" for any tab,
|
|
67
|
+
* any machine, days later. What stood here instead was a text box asking the
|
|
68
|
+
* reader to paste an id they would have had to write down, which is the same
|
|
69
|
+
* information behind a worse door.
|
|
70
|
+
*/
|
|
71
|
+
export function History({
|
|
72
|
+
runs,
|
|
73
|
+
error,
|
|
74
|
+
openId,
|
|
75
|
+
onOpen,
|
|
76
|
+
}: {
|
|
77
|
+
runs: WorkflowRun<Transcript>[];
|
|
78
|
+
error: string | undefined;
|
|
79
|
+
openId: string | undefined;
|
|
80
|
+
onOpen: (runId: string) => void;
|
|
81
|
+
}) {
|
|
82
|
+
return (
|
|
83
|
+
<section className="flex flex-col gap-3 border-t pt-6">
|
|
84
|
+
<h2 className="text-sm font-medium uppercase tracking-[1.2px]">Previous runs</h2>
|
|
85
|
+
{error !== undefined && <p className="text-sm text-red-600">{error}</p>}
|
|
86
|
+
{runs.length === 0 && error === undefined && (
|
|
87
|
+
<p className="text-sm opacity-60">Nothing transcribed yet.</p>
|
|
88
|
+
)}
|
|
89
|
+
<ul className="flex flex-col">
|
|
90
|
+
{runs.map((entry) => (
|
|
91
|
+
<li key={entry.runId} className="border-b last:border-b-0">
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={() => onOpen(entry.runId)}
|
|
95
|
+
className="flex w-full items-baseline justify-between gap-4 py-2 text-left text-sm"
|
|
96
|
+
>
|
|
97
|
+
<span className="truncate">{title(entry)}</span>
|
|
98
|
+
<span className="shrink-0 text-xs opacity-60">{STATUS_LINE[entry.status]}</span>
|
|
99
|
+
</button>
|
|
100
|
+
{openId === entry.runId && <RunPanel run={entry} />}
|
|
101
|
+
</li>
|
|
102
|
+
))}
|
|
103
|
+
</ul>
|
|
104
|
+
</section>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One line naming a past run.
|
|
110
|
+
*
|
|
111
|
+
* The FILE where there is one — `mergeTranscript` puts the recording's own name
|
|
112
|
+
* in the output for exactly this — falling back to the id, which is all a run
|
|
113
|
+
* that failed before it read the upload ever had.
|
|
114
|
+
*/
|
|
115
|
+
function title(run: WorkflowRun<Transcript>): string {
|
|
116
|
+
if (run.status === "completed") return run.output.source;
|
|
117
|
+
return run.runId;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The run's status, its narration, and its transcript once there is one. */
|
|
121
|
+
export function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onClear?: () => void }) {
|
|
122
|
+
return (
|
|
123
|
+
<section className="flex flex-col gap-3 rounded-md border p-5">
|
|
124
|
+
<div className="flex items-baseline justify-between gap-4">
|
|
125
|
+
<h2 className="text-sm font-medium uppercase tracking-[1.2px]">
|
|
126
|
+
{STATUS_LINE[run.status]}
|
|
127
|
+
</h2>
|
|
128
|
+
{onClear && (
|
|
129
|
+
<button type="button" onClick={onClear} className="text-xs underline opacity-60">
|
|
130
|
+
Clear
|
|
131
|
+
</button>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{/* The run's own narration, oldest first — the complement of `STATUS_LINE`
|
|
136
|
+
above, and the reason both exist: the status is `running` for the whole
|
|
137
|
+
fan-out, so a sixty-segment recording and a one-segment recording look
|
|
138
|
+
identical while they run. These lines come from the run itself
|
|
139
|
+
(`report()` in `workflows/transcribe.ts`), and they REPLAY, so looking a
|
|
140
|
+
finished run up in the panel below shows how it got there. */}
|
|
141
|
+
<WorkflowProgress runId={run.runId} />
|
|
142
|
+
|
|
143
|
+
{/* While it runs, the transcript so far. Unguarded on the run's status
|
|
144
|
+
beyond this: the component renders nothing until a segment has landed,
|
|
145
|
+
and stops the moment there is an `output` to render instead. */}
|
|
146
|
+
{!isTerminal(run) && <LiveTranscript runId={run.runId} />}
|
|
147
|
+
|
|
148
|
+
{/* Discriminated on `status`, so `output` and `error` are reachable
|
|
149
|
+
without a cast — the reason a snapshot is a union rather than a flat
|
|
150
|
+
object with optional fields. */}
|
|
151
|
+
{run.status === "completed" && (
|
|
152
|
+
<>
|
|
153
|
+
<p className="text-xs opacity-60">
|
|
154
|
+
{run.output.segments} {plural(run.output.segments, "segment")} ·{" "}
|
|
155
|
+
{formatDuration(run.output.durationMs)} of audio · took{" "}
|
|
156
|
+
{formatDuration(run.output.elapsedMs)} · {run.output.words} words
|
|
157
|
+
</p>
|
|
158
|
+
<pre className="whitespace-pre-wrap text-sm leading-relaxed">{run.output.transcript}</pre>
|
|
159
|
+
</>
|
|
160
|
+
)}
|
|
161
|
+
{run.status === "failed" && <p className="text-red-600">{run.error}</p>}
|
|
162
|
+
</section>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The transcript as it arrives, stitched from the segments that have landed.
|
|
168
|
+
*
|
|
169
|
+
* The other half of `<WorkflowProgress>` above it: that one renders what the run
|
|
170
|
+
* SAYS about itself, this one renders what it has produced. Both are the same
|
|
171
|
+
* mechanism — a run's output stream — separated by the namespace, which is what
|
|
172
|
+
* lets this one be typed.
|
|
173
|
+
*
|
|
174
|
+
* It renders NOTHING until a segment lands, so a page can mount it unguarded:
|
|
175
|
+
* before the first chunk there is nothing to say that the progress log is not
|
|
176
|
+
* already saying better.
|
|
177
|
+
*
|
|
178
|
+
* The count is derived from the stitched text rather than summed per chunk,
|
|
179
|
+
* because the seams overlap — adding up the segments would over-count every one
|
|
180
|
+
* of them by a couple of seconds' worth of words.
|
|
181
|
+
*/
|
|
182
|
+
function LiveTranscript({ runId }: { runId: string }) {
|
|
183
|
+
const { progress } = useWorkflowProgress<TranscriptChunk>(runId, {
|
|
184
|
+
namespace: TRANSCRIPT_STREAM,
|
|
185
|
+
});
|
|
186
|
+
// Memoized on the ARRAY, which the hook appends to per read: stitching is a
|
|
187
|
+
// seam search per segment, and a fan-out re-renders this panel on every
|
|
188
|
+
// progress poll whether or not anything arrived.
|
|
189
|
+
const transcript = useMemo(() => stitchChunks(progress), [progress]);
|
|
190
|
+
if (progress.length === 0) return null;
|
|
191
|
+
|
|
192
|
+
// The furthest point reached, not the count: segments land out of order, so
|
|
193
|
+
// "6 segments" says nothing about how much of the recording is covered.
|
|
194
|
+
const covered = Math.max(...progress.map((chunk) => chunk.endMs));
|
|
195
|
+
return (
|
|
196
|
+
<div className="flex flex-col gap-2">
|
|
197
|
+
<p className="text-xs opacity-60">
|
|
198
|
+
{countWords(transcript)} words so far · through {formatDuration(covered)}
|
|
199
|
+
</p>
|
|
200
|
+
<pre className="whitespace-pre-wrap text-sm leading-relaxed opacity-80">{transcript}</pre>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* Press to transcript, which is the one number neither bar can give.
|
|
4
|
+
*
|
|
5
|
+
* The upload bar and the progress log describe the two stretches separately, and
|
|
6
|
+
* neither answers the question a reader comparing the three modes is actually
|
|
7
|
+
* asking. Nothing on the server can answer it either — `output.elapsedMs` is the
|
|
8
|
+
* RUN's own wall clock, so in the two modes that store the file first it begins
|
|
9
|
+
* after the upload and misses most of the wait. Only the browser holds both
|
|
10
|
+
* ends.
|
|
11
|
+
*
|
|
12
|
+
* Its own module because it is a self-contained instrument — a stopwatch and the
|
|
13
|
+
* one section that prints it — and `client.tsx` is the file a reader goes to for
|
|
14
|
+
* the page's SHAPE.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { formatDuration } from "@alexkroman1/aai/utils";
|
|
18
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How often the running stopwatch re-renders.
|
|
22
|
+
*
|
|
23
|
+
* Under a second, so the displayed seconds turn over promptly rather than up to a
|
|
24
|
+
* second late; nothing reads this value, since the elapsed time is measured from
|
|
25
|
+
* the clock at render (see {@link useTotalLatency}).
|
|
26
|
+
*/
|
|
27
|
+
const STOPWATCH_TICK_MS = 250;
|
|
28
|
+
|
|
29
|
+
/** What {@link useTotalLatency} reports. */
|
|
30
|
+
export type TotalLatency = {
|
|
31
|
+
/**
|
|
32
|
+
* Milliseconds since the submit — ticking while the submission is in flight,
|
|
33
|
+
* frozen at the finish, and undefined before the first one.
|
|
34
|
+
*/
|
|
35
|
+
elapsedMs: number | undefined;
|
|
36
|
+
/** Whether the clock is still running, which is what makes the label honest. */
|
|
37
|
+
running: boolean;
|
|
38
|
+
/** Start (or restart) the clock. Called from the form's own submit handler. */
|
|
39
|
+
start: () => void;
|
|
40
|
+
/** Drop it, for a panel that no longer describes the submission it timed. */
|
|
41
|
+
clear: () => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Wall clock from the submit to the finish, across both waits.
|
|
46
|
+
*
|
|
47
|
+
* `inFlight` is the submission's own `pending` — true from `submit()` until the run
|
|
48
|
+
* reaches a terminal status — so the clock covers the upload, the run, and the
|
|
49
|
+
* gap between them, which is the whole of what a reader waits for and is the one
|
|
50
|
+
* measurement no server-side number can make.
|
|
51
|
+
*
|
|
52
|
+
* Two details it would be easy to get wrong:
|
|
53
|
+
*
|
|
54
|
+
* - **The interval re-renders; it does not accumulate.** The elapsed time is read
|
|
55
|
+
* from the clock at render, so a tick the tab throttled or dropped cannot make
|
|
56
|
+
* the number lag behind real time.
|
|
57
|
+
* - **`performance.now()`, not `Date.now()`.** It is monotonic, so a clock
|
|
58
|
+
* correction (NTP, a laptop waking up) cannot make a transcription look
|
|
59
|
+
* instant — or negative.
|
|
60
|
+
*/
|
|
61
|
+
export function useTotalLatency(inFlight: boolean): TotalLatency {
|
|
62
|
+
const [startedAt, setStartedAt] = useState<number | undefined>(undefined);
|
|
63
|
+
const [frozenMs, setFrozenMs] = useState<number | undefined>(undefined);
|
|
64
|
+
// Re-render trigger only — see the doc above.
|
|
65
|
+
const [, tick] = useState(0);
|
|
66
|
+
// Whether `inFlight` has been seen true since the last `start()`. Without it,
|
|
67
|
+
// a start that lands one render before the submission reports itself in flight
|
|
68
|
+
// would freeze the clock at zero instead of running it.
|
|
69
|
+
const began = useRef(false);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (startedAt === undefined || frozenMs !== undefined) return;
|
|
73
|
+
if (inFlight) {
|
|
74
|
+
began.current = true;
|
|
75
|
+
const id = setInterval(() => tick((n) => n + 1), STOPWATCH_TICK_MS);
|
|
76
|
+
return () => clearInterval(id);
|
|
77
|
+
}
|
|
78
|
+
// Measured here rather than at render, so the frozen number is the one at the
|
|
79
|
+
// moment the run settled rather than whenever this page next drew.
|
|
80
|
+
if (began.current) setFrozenMs(performance.now() - startedAt);
|
|
81
|
+
}, [startedAt, frozenMs, inFlight]);
|
|
82
|
+
|
|
83
|
+
const start = useCallback(() => {
|
|
84
|
+
began.current = false;
|
|
85
|
+
setFrozenMs(undefined);
|
|
86
|
+
setStartedAt(performance.now());
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
const clear = useCallback(() => {
|
|
90
|
+
began.current = false;
|
|
91
|
+
setStartedAt(undefined);
|
|
92
|
+
setFrozenMs(undefined);
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
elapsedMs: frozenMs ?? (startedAt === undefined ? undefined : performance.now() - startedAt),
|
|
97
|
+
running: startedAt !== undefined && frozenMs === undefined,
|
|
98
|
+
start,
|
|
99
|
+
clear,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The one number the two bars cannot give: click to transcript.
|
|
105
|
+
*
|
|
106
|
+
* Rendered above the run panel rather than inside it, because the stretch it
|
|
107
|
+
* covers starts before there IS a run — in two of the three modes the run does
|
|
108
|
+
* not exist until the upload finishes, so a clock living in the panel would
|
|
109
|
+
* appear only after the wait it is supposed to be timing.
|
|
110
|
+
*
|
|
111
|
+
* `runMs` is the run's own elapsed, once it reports one. The remainder is
|
|
112
|
+
* everything the run could not see: storing the file (or, in streaming mode,
|
|
113
|
+
* minting the upload id), the `POST` that starts the run, and the poll that
|
|
114
|
+
* notices it finished. Clamped at zero, because the two numbers come from two
|
|
115
|
+
* different clocks on two different machines and a few milliseconds the wrong way
|
|
116
|
+
* would otherwise print a negative.
|
|
117
|
+
*/
|
|
118
|
+
export function TotalLatency({
|
|
119
|
+
elapsedMs,
|
|
120
|
+
running,
|
|
121
|
+
runMs,
|
|
122
|
+
}: {
|
|
123
|
+
elapsedMs: number | undefined;
|
|
124
|
+
running: boolean;
|
|
125
|
+
runMs: number | undefined;
|
|
126
|
+
}) {
|
|
127
|
+
if (elapsedMs === undefined) return null;
|
|
128
|
+
const outside = runMs === undefined ? undefined : Math.max(0, elapsedMs - runMs);
|
|
129
|
+
return (
|
|
130
|
+
<section className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 rounded-md border px-5 py-3">
|
|
131
|
+
<h2 className="text-sm font-medium uppercase tracking-[1.2px]">
|
|
132
|
+
{running ? "Elapsed" : "Total latency"}
|
|
133
|
+
</h2>
|
|
134
|
+
<span className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
|
135
|
+
<span className="text-sm tabular-nums">{formatDuration(elapsedMs)}</span>
|
|
136
|
+
{runMs !== undefined && outside !== undefined && (
|
|
137
|
+
<span className="text-xs tabular-nums opacity-60">
|
|
138
|
+
{formatDuration(outside)} before the run · {formatDuration(runMs)} inside it
|
|
139
|
+
</span>
|
|
140
|
+
)}
|
|
141
|
+
</span>
|
|
142
|
+
</section>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -33,10 +33,9 @@
|
|
|
33
33
|
*
|
|
34
34
|
* What stays here is what a dependency cannot decide: how many steps to cut the job
|
|
35
35
|
* into, and therefore what is journaled and what a retry repeats. That is also
|
|
36
|
-
* structural rather than stylistic — the
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* journal and no retry, silently.
|
|
36
|
+
* structural rather than stylistic — only the caller with a `ctx` can open a
|
|
37
|
+
* step, so a step boundary shipped inside the SDK would be no boundary at all:
|
|
38
|
+
* it would run inline, with no journal and no retry, silently.
|
|
40
39
|
*
|
|
41
40
|
* ## The one thing that makes this a WORKFLOW rather than a request
|
|
42
41
|
*
|
|
@@ -54,6 +53,7 @@
|
|
|
54
53
|
* request rather than two and the value journaled by the last poll IS the result.
|
|
55
54
|
*/
|
|
56
55
|
|
|
56
|
+
import type { WorkflowCtx } from "@alexkroman1/aai";
|
|
57
57
|
import { report, TRANSCRIBE_API, uploadInfo } from "@alexkroman1/aai/step";
|
|
58
58
|
import {
|
|
59
59
|
stepTranscribePollClassified,
|
|
@@ -61,38 +61,44 @@ import {
|
|
|
61
61
|
stepTranscribeUploadClassified,
|
|
62
62
|
} from "@alexkroman1/aai/step-errors";
|
|
63
63
|
import { countWords, formatBytes } from "@alexkroman1/aai/utils";
|
|
64
|
-
import { sleep } from "workflow";
|
|
65
64
|
import { startClock, type Transcript } from "./transcribe.ts";
|
|
66
65
|
|
|
67
66
|
/** How long between polls of a submitted job. */
|
|
68
|
-
const
|
|
67
|
+
const POLL_INTERVAL_MS = 10_000;
|
|
69
68
|
|
|
70
69
|
/**
|
|
71
70
|
* Polls before the run gives up on a job.
|
|
72
71
|
*
|
|
73
|
-
* At {@link
|
|
72
|
+
* At {@link POLL_INTERVAL_MS} this is an hour, well past what the async API takes for
|
|
74
73
|
* any recording it accepts. Bounded rather than endless because a job that never
|
|
75
74
|
* leaves `queued` is a run that would otherwise be replayed forever.
|
|
76
75
|
*/
|
|
77
76
|
const MAX_POLLS = 360;
|
|
78
77
|
|
|
79
78
|
/** Transcribe a recording through the async API. */
|
|
80
|
-
export async function transcribeBatchFlow(
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
export async function transcribeBatchFlow(
|
|
80
|
+
input: { recording: string },
|
|
81
|
+
ctx: WorkflowCtx,
|
|
82
|
+
): Promise<Transcript> {
|
|
83
83
|
// Both at once: the clock does not depend on the upload, and issuing them
|
|
84
84
|
// together costs one round trip instead of two before a byte moves. Their issue
|
|
85
85
|
// order is still decided by this line rather than by which lands first.
|
|
86
|
+
// `maxAttempts: 6` was `uploadToProvider.maxRetries = 5` — five retries after
|
|
87
|
+
// the first, so six in all. More than the default 3 because an upload is the
|
|
88
|
+
// one call here worth another attempt: it moves the whole recording, and a lost
|
|
89
|
+
// connection on a file this size is the expected failure.
|
|
86
90
|
const [startedAt, { audioUrl }] = await Promise.all([
|
|
87
|
-
startClock(),
|
|
88
|
-
uploadToProvider(input.recording),
|
|
91
|
+
ctx.step("startClock", () => startClock()),
|
|
92
|
+
ctx.step("uploadToProvider", () => uploadToProvider(input.recording), { maxAttempts: 6 }),
|
|
89
93
|
]);
|
|
90
|
-
const job = await createJob(audioUrl);
|
|
94
|
+
const job = await ctx.step("createJob", () => createJob(audioUrl));
|
|
91
95
|
|
|
92
96
|
for (let poll = 0; poll < MAX_POLLS; poll += 1) {
|
|
93
|
-
const progress = await pollTranscript
|
|
97
|
+
const progress = await ctx.step("pollTranscript", () =>
|
|
98
|
+
pollTranscript(input.recording, job.id, startedAt),
|
|
99
|
+
);
|
|
94
100
|
if (progress.done) return progress.transcript;
|
|
95
|
-
await sleep(
|
|
101
|
+
await ctx.sleep(POLL_INTERVAL_MS);
|
|
96
102
|
}
|
|
97
103
|
// A plain throw: this is the BODY, where the fatal/retryable distinction has
|
|
98
104
|
// nothing to apply to — see `stream.ts`'s `abandon` for the same reasoning.
|
|
@@ -123,8 +129,6 @@ export async function transcribeBatchFlow(input: { recording: string }): Promise
|
|
|
123
129
|
* same reason.
|
|
124
130
|
*/
|
|
125
131
|
export async function uploadToProvider(uploadId: string): Promise<{ audioUrl: string }> {
|
|
126
|
-
"use step";
|
|
127
|
-
|
|
128
132
|
const stored = await uploadInfo(uploadId);
|
|
129
133
|
await report(
|
|
130
134
|
`Uploading ${stored.name || uploadId} (${formatBytes(stored.size)}) to the async API.`,
|
|
@@ -132,13 +136,8 @@ export async function uploadToProvider(uploadId: string): Promise<{ audioUrl: st
|
|
|
132
136
|
return await stepTranscribeUploadClassified(uploadId);
|
|
133
137
|
}
|
|
134
138
|
|
|
135
|
-
/** Retries beyond the default 3: an upload is the one call here worth another attempt. */
|
|
136
|
-
uploadToProvider.maxRetries = 5;
|
|
137
|
-
|
|
138
139
|
/** Create the transcription job, and answer with the id that outlives this run. */
|
|
139
140
|
export async function createJob(audioUrl: string): Promise<{ id: string }> {
|
|
140
|
-
"use step";
|
|
141
|
-
|
|
142
141
|
const job = await stepTranscribeSubmitClassified(audioUrl);
|
|
143
142
|
await report(`Submitted — job ${job.id}.`);
|
|
144
143
|
return job;
|
|
@@ -157,8 +156,6 @@ export async function pollTranscript(
|
|
|
157
156
|
id: string,
|
|
158
157
|
startedAt: number,
|
|
159
158
|
): Promise<{ done: false } | { done: true; transcript: Transcript }> {
|
|
160
|
-
"use step";
|
|
161
|
-
|
|
162
159
|
const progress = await stepTranscribePollClassified(id);
|
|
163
160
|
if (!progress.done) {
|
|
164
161
|
await report(`Transcript ${id} is ${progress.status}.`);
|