@alexkroman1/aai-cli 13.3.0 → 15.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{_agent-Clbq3a4h.mjs → _agent-BzUeqOdj.mjs} +32 -9
- package/dist/_agent.d.ts +23 -0
- package/dist/_artifacts-BJOYGQPp.mjs +21 -0
- package/dist/_artifacts.d.ts +16 -0
- package/dist/_build-target.d.ts +172 -0
- package/dist/{_bundler-DEkjO1d8.mjs → _bundler-DM0d0M7m.mjs} +8 -7
- package/dist/_cli-common.d.ts +45 -6
- package/dist/{_config-ZY2euhdh.mjs → _config-DYzC6WMD.mjs} +2 -2
- package/dist/_dev-env.d.ts +1 -1
- package/dist/{_dev-server-C13xuGB1.mjs → _dev-server-BzWB6-4y.mjs} +25 -24
- package/dist/_dev-vite-config.d.ts +3 -3
- package/dist/_e2e-test-utils.d.ts +1 -1
- package/dist/{_init-Ci4QGjp8.mjs → _init-Bsi3DZNJ.mjs} +9 -23
- package/dist/{_output-DbJt9gsl.mjs → _output-DBP9Op_d.mjs} +22 -13
- package/dist/_output.d.ts +10 -9
- package/dist/{_preflight-B2laWPdU.mjs → _preflight-CcAR577V.mjs} +2 -2
- package/dist/_resource-commands.d.ts +9 -9
- package/dist/_server-common-De0haHr9.mjs +70 -0
- package/dist/_server-common.d.ts +20 -1
- package/dist/{_slug-api-DlKpj91X.mjs → _slug-api-CGvc2YSQ.mjs} +18 -9
- package/dist/_slug-api.d.ts +19 -0
- package/dist/{_studio-DTi3EdXQ.mjs → _studio-DnR_BqFp.mjs} +7 -3
- package/dist/_studio-commands.d.ts +13 -13
- package/dist/{_templates-V4l5sZJy.mjs → _templates-CIlJ3Vay.mjs} +4 -4
- package/dist/_templates.d.ts +1 -1
- package/dist/{_typecheck-gate-sV4N5F93.mjs → _typecheck-gate-U3jcil8L.mjs} +3 -2
- package/dist/_typecheck-gate.d.ts +3 -1
- package/dist/{_utils-Dhd058D9.mjs → _utils-D5JGcjiW.mjs} +43 -1
- package/dist/_utils.d.ts +35 -0
- package/dist/_vercel-output.d.ts +63 -0
- package/dist/{_workflow-determinism-C9l8uC2R.mjs → _workflow-determinism-CvpqMpdh.mjs} +18 -26
- package/dist/_workflow-determinism.d.ts +3 -3
- package/dist/build-BhEaxBPu.mjs +481 -0
- package/dist/build.d.ts +23 -10
- package/dist/cli.d.ts +1 -2
- package/dist/cli.mjs +155 -138
- package/dist/{client-bundler-CT-_26c5.mjs → client-bundler-6mTLs6ny.mjs} +5 -5
- package/dist/client-bundler.d.ts +1 -1
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-RqlQZ6Y_.mjs → delete-BdFnbnVR.mjs} +4 -4
- package/dist/{deploy-B7f3CK7v.mjs → deploy-CGqPU5U-.mjs} +11 -11
- package/dist/{dev-DamXieW_.mjs → dev-Bx9gYBHM.mjs} +3 -3
- package/dist/{eval-h3DTVfRw.mjs → eval-B3I7FqN9.mjs} +3 -3
- package/dist/{init-D-vFrV8h.mjs → init-CFyusRbq.mjs} +48 -20
- package/dist/init.d.ts +14 -0
- package/dist/{login-CDiemNmC.mjs → login-DaLrPOFg.mjs} +4 -6
- package/dist/{logs-DGwPNCzB.mjs → logs-7BRwq0cG.mjs} +21 -14
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +155 -76
- package/dist/scaffold/package.json +6 -6
- package/dist/{secret-V1w7y9-j.mjs → secret-Cjp912yF.mjs} +5 -5
- package/dist/secret.d.ts +8 -6
- package/dist/start.d.ts +112 -0
- package/dist/start.mjs +156 -0
- package/dist/{studio-BpMCfUNx.mjs → studio-C_zuRC_z.mjs} +13 -15
- package/dist/templates/briefing-desk/agent.eval.test.ts +156 -0
- package/dist/templates/call-audit/agent.test.ts +11 -6
- package/dist/templates/call-audit/agent.ts +2 -2
- package/dist/templates/call-audit/client.tsx +27 -42
- package/dist/templates/call-audit/workflows/audit.ts +7 -5
- package/dist/templates/call-audit/workflows/ingest.ts +7 -7
- package/dist/templates/call-audit/workflows/media.ts +12 -37
- package/dist/templates/call-audit/workflows/summarize.ts +8 -8
- package/dist/templates/call-audit/workflows/sync-api.ts +3 -3
- package/dist/templates/code-interpreter/agent.test.ts +103 -0
- package/dist/templates/dispatch-center/agent.test.ts +7 -7
- package/dist/templates/dispatch-center/client.tsx +53 -61
- package/dist/templates/dispatch-center/shared.ts +0 -8
- package/dist/templates/dispatch-center/tools/ops_dashboard.ts +4 -1
- package/dist/templates/embedded-assets/agent.eval.test.ts +4 -2
- package/dist/templates/health-assistant/agent.eval.test.ts +4 -2
- package/dist/templates/infocom-adventure/client.tsx +40 -36
- package/dist/templates/link-digest/agent.test.ts +3 -3
- package/dist/templates/link-digest/agent.ts +2 -2
- package/dist/templates/link-digest/client.tsx +81 -19
- package/dist/templates/link-digest/workflows/digest.ts +13 -16
- package/dist/templates/math-buddy/agent.test.ts +126 -0
- package/dist/templates/night-owl/agent.eval.test.ts +4 -2
- package/dist/templates/night-owl/client.tsx +8 -2
- package/dist/templates/personal-finance/agent.test.ts +127 -0
- package/dist/templates/pipeline-simple/agent.test.ts +2 -2
- package/dist/templates/pizza-ordering/client.tsx +5 -4
- package/dist/templates/pizza-ordering/shared.ts +3 -8
- package/dist/templates/pizza-ordering/tools/add_pizza.ts +3 -2
- package/dist/templates/pizza-ordering/tools/place_order.ts +3 -2
- package/dist/templates/pizza-ordering/tools/remove_pizza.ts +3 -2
- package/dist/templates/pizza-ordering/tools/update_pizza.ts +3 -2
- package/dist/templates/pizza-ordering/tools/view_order.ts +3 -2
- package/dist/templates/plan-and-execute/agent.test.ts +10 -5
- package/dist/templates/plan-and-execute/client.tsx +2 -2
- package/dist/templates/podcast-digest/agent.test.ts +20 -11
- package/dist/templates/podcast-digest/client.tsx +16 -22
- package/dist/templates/podcast-digest/workflows/digest.ts +21 -19
- package/dist/templates/podcast-digest/workflows/feeds.ts +61 -33
- package/dist/templates/podcast-digest/workflows/slack.ts +7 -7
- package/dist/templates/recap-workflow/agent.eval.test.ts +21 -13
- package/dist/templates/recap-workflow/agent.test.ts +27 -23
- package/dist/templates/recap-workflow/workflows/recap.ts +24 -28
- package/dist/templates/redline/agent.eval.test.ts +5 -8
- package/dist/templates/redline/agent.test.ts +1 -1
- package/dist/templates/redline/client.tsx +36 -34
- package/dist/templates/redline/workflows/redline.ts +17 -17
- package/dist/templates/research-workflow/agent.eval.test.ts +5 -5
- package/dist/templates/research-workflow/agent.test.ts +10 -7
- package/dist/templates/research-workflow/workflows/research.ts +28 -30
- package/dist/templates/retail/agent.test.ts +17 -15
- package/dist/templates/retail/cancel.ts +7 -6
- package/dist/templates/retail/client.tsx +39 -58
- package/dist/templates/retail/payment.ts +4 -3
- package/dist/templates/retail/pending.test.ts +19 -19
- package/dist/templates/retail/store.test.ts +5 -5
- package/dist/templates/retail/swap.ts +8 -11
- package/dist/templates/solo-rpg/agent.eval.test.ts +2 -16
- package/dist/templates/solo-rpg/agent.test.ts +12 -12
- package/dist/templates/solo-rpg/client.tsx +3 -3
- package/dist/templates/spoken-summary/agent.test.ts +9 -9
- package/dist/templates/spoken-summary/agent.ts +3 -3
- package/dist/templates/spoken-summary/client.tsx +23 -30
- package/dist/templates/spoken-summary/workflows/summarize.ts +11 -11
- package/dist/templates/spoken-summary/workflows/transcribe.ts +14 -14
- package/dist/templates/support-line/agent.ts +8 -0
- package/dist/templates/support-line/client.tsx +4 -3
- package/dist/templates/support-line/prompts.ts +0 -2
- package/dist/templates/transcription-workflow/agent.test.ts +20 -12
- package/dist/templates/transcription-workflow/agent.ts +2 -2
- package/dist/templates/transcription-workflow/client.tsx +15 -14
- package/dist/templates/transcription-workflow/recover.ts +1 -1
- package/dist/templates/transcription-workflow/run-panel.tsx +34 -14
- package/dist/templates/transcription-workflow/total-latency.tsx +46 -20
- package/dist/templates/transcription-workflow/workflows/batch.ts +15 -15
- package/dist/templates/transcription-workflow/workflows/downsample.ts +1 -1
- package/dist/templates/transcription-workflow/workflows/normalize.ts +15 -19
- package/dist/templates/transcription-workflow/workflows/stream.ts +12 -12
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +3 -3
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -34
- package/dist/templates/travel-concierge/agent.eval.test.ts +2 -2
- package/dist/templates/travel-concierge/agent.test.ts +15 -7
- package/dist/templates/travel-concierge/client.tsx +2 -2
- package/dist/templates/travel-concierge/routing.ts +64 -55
- package/dist/templates/travel-concierge/shared.ts +10 -14
- package/dist/templates/travel-concierge/tools/cancel_action.ts +3 -1
- package/dist/templates/travel-concierge/tools/complete_or_escalate.ts +3 -1
- package/dist/templates/travel-concierge/tools/confirm_action.ts +3 -1
- package/dist/templates/travel-concierge/tools/lookup_booking.ts +4 -3
- package/dist/templates/travel-concierge/tools/search_car_rentals.ts +3 -2
- package/dist/templates/travel-concierge/tools/search_excursions.ts +3 -2
- package/dist/templates/travel-concierge/tools/search_flights.ts +3 -2
- package/dist/templates/travel-concierge/tools/search_hotels.ts +3 -2
- package/dist/templates/web-researcher/agent.test.ts +130 -0
- package/dist/{test-guenJUxi.mjs → test-D032V2cA.mjs} +4 -17
- package/dist/typecheck.mjs +1 -1
- package/dist/worker-bundler.d.ts +1 -1
- package/dist/worker-bundler.mjs +8 -8
- package/dist/{workflow-BZyKLkhV.mjs → workflow-CVioyO3V.mjs} +6 -5
- package/package.json +9 -4
- package/dist/_server-common-BV4_CrP3.mjs +0 -43
- package/dist/build-CWyFGe-W.mjs +0 -69
- package/dist/scaffold/server.mjs +0 -204
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
* its return value, and audio in one is megabytes replayed on every resume.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import { stepReadUpload, stepUploadInfo } from "@alexkroman1/aai/step";
|
|
21
21
|
import { FatalError, RetryableError } from "@alexkroman1/aai/step-errors";
|
|
22
|
-
import {
|
|
22
|
+
import { createWorkflowContext, stubGatewayRoute } from "@alexkroman1/aai/testing";
|
|
23
23
|
import {
|
|
24
24
|
installStubGateway,
|
|
25
25
|
installStubReporter,
|
|
@@ -215,14 +215,14 @@ describe("speaking", () => {
|
|
|
215
215
|
|
|
216
216
|
const { audio } = await speak("Hello.");
|
|
217
217
|
|
|
218
|
-
await expect(
|
|
218
|
+
await expect(stepUploadInfo(audio)).resolves.toMatchObject({
|
|
219
219
|
name: "summary.wav",
|
|
220
220
|
// The byte route serves this as `Content-Type`, and a browser will not
|
|
221
221
|
// play inline a file it was handed as octet-stream.
|
|
222
222
|
type: "audio/wav",
|
|
223
223
|
size: 44 + 4000,
|
|
224
224
|
});
|
|
225
|
-
const { bytes } = await
|
|
225
|
+
const { bytes } = await stepReadUpload(audio, { end: 12 });
|
|
226
226
|
expect(String.fromCharCode(...bytes.subarray(0, 4))).toBe("RIFF");
|
|
227
227
|
expect(String.fromCharCode(...bytes.subarray(8, 12))).toBe("WAVE");
|
|
228
228
|
});
|
|
@@ -292,7 +292,7 @@ describe("the whole run", () => {
|
|
|
292
292
|
installStubReporter();
|
|
293
293
|
installStubSpeech();
|
|
294
294
|
|
|
295
|
-
const summary = await spokenSummaryFlow({ recording: UPLOAD_ID },
|
|
295
|
+
const summary = await spokenSummaryFlow({ recording: UPLOAD_ID }, createWorkflowContext());
|
|
296
296
|
|
|
297
297
|
expect(summary).toEqual({
|
|
298
298
|
source: "standup.wav",
|
|
@@ -314,7 +314,7 @@ describe("the whole run", () => {
|
|
|
314
314
|
installStubReporter();
|
|
315
315
|
const speech = installStubSpeech();
|
|
316
316
|
|
|
317
|
-
await spokenSummaryFlow({ recording: UPLOAD_ID, voice: "michael" },
|
|
317
|
+
await spokenSummaryFlow({ recording: UPLOAD_ID, voice: "michael" }, createWorkflowContext());
|
|
318
318
|
|
|
319
319
|
expect(speech.calls[0]).toMatchObject({ text: "Spoken.", voice: "michael" });
|
|
320
320
|
});
|
|
@@ -324,9 +324,9 @@ describe("the whole run", () => {
|
|
|
324
324
|
installStubReporter();
|
|
325
325
|
installStubSpeech();
|
|
326
326
|
|
|
327
|
-
await expect(
|
|
328
|
-
|
|
329
|
-
);
|
|
327
|
+
await expect(
|
|
328
|
+
spokenSummaryFlow({ recording: UPLOAD_ID }, createWorkflowContext()),
|
|
329
|
+
).rejects.toThrow("corrupt audio");
|
|
330
330
|
});
|
|
331
331
|
});
|
|
332
332
|
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* event stream wired into a live pipeline's playback, with a turn tracker and
|
|
25
25
|
* barge-in behind it, and a step has no turn to be part of and has to return
|
|
26
26
|
* a VALUE.
|
|
27
|
-
* - **`
|
|
27
|
+
* - **`stepWriteUpload`** (same subpath) puts that value where a browser can reach
|
|
28
28
|
* it. A run's OUTPUT is read back as JSON, so audio cannot travel in one —
|
|
29
29
|
* the same rule that keeps a recording's bytes out of a run's INPUT, arriving
|
|
30
30
|
* at the other end of the run.
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
* `POST /workflows/uploads` (the browser does this for you: `uploads` below is
|
|
55
55
|
* what makes `<WorkflowFields>` render a file picker, and `useWorkflowSubmit`
|
|
56
56
|
* stores the file before starting the run), the input carries the returned id,
|
|
57
|
-
* and the step that needs the bytes streams them out with `
|
|
57
|
+
* and the step that needs the bytes streams them out with `stepReadUpload`.
|
|
58
58
|
*
|
|
59
59
|
* ## It is scriptable, which is the other half of having an API
|
|
60
60
|
*
|
|
@@ -140,7 +140,7 @@ export const spokenSummary: WorkflowDef<typeof spokenSummaryInput, SpokenSummary
|
|
|
140
140
|
input: spokenSummaryInput,
|
|
141
141
|
// The one line that makes the form take a file: `<WorkflowFields>` renders a
|
|
142
142
|
// picker for this property, `useWorkflowSubmit` stores the chosen file, and
|
|
143
|
-
// the step that transcribes it reads it back with `
|
|
143
|
+
// the step that transcribes it reads it back with `stepReadUpload`.
|
|
144
144
|
uploads: ["recording"],
|
|
145
145
|
run: spokenSummaryFlow,
|
|
146
146
|
});
|
|
@@ -69,9 +69,11 @@ import "@alexkroman1/aai-ui/styles.css";
|
|
|
69
69
|
// `workflows/summarize.ts` already declares.
|
|
70
70
|
import { formatDuration } from "@alexkroman1/aai/utils";
|
|
71
71
|
import {
|
|
72
|
+
BulletList,
|
|
72
73
|
createWorkflowApi,
|
|
74
|
+
Facts,
|
|
73
75
|
Form,
|
|
74
|
-
|
|
76
|
+
mountPage,
|
|
75
77
|
SubmitButton,
|
|
76
78
|
UploadProgressBar,
|
|
77
79
|
useDownloadUrl,
|
|
@@ -79,7 +81,6 @@ import {
|
|
|
79
81
|
WorkflowFields,
|
|
80
82
|
WorkflowProgress,
|
|
81
83
|
} from "@alexkroman1/aai-ui";
|
|
82
|
-
import { useState } from "react";
|
|
83
84
|
import type { spokenSummary } from "./agent.ts";
|
|
84
85
|
|
|
85
86
|
/**
|
|
@@ -128,16 +129,11 @@ function captionsUrl(text: string, durationMs: number): string {
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
export function App() {
|
|
131
|
-
// Did THIS load start the run? A reload cannot have, and that is the only way
|
|
132
|
-
// the page can tell "working on what you just sent" from "picking up where
|
|
133
|
-
// you left off" — the hook reports the run, not who asked for it.
|
|
134
|
-
const [startedHere, setStartedHere] = useState(false);
|
|
135
132
|
// The generic is what makes `run.status === "completed"` narrow to a TYPED
|
|
136
133
|
// `run.output` instead of `unknown`. The reload is the hook's own doing — see
|
|
137
134
|
// the module doc for why the key it mints is the right one for this page.
|
|
138
|
-
const { submitForm, run, pending, upload, pauseUpload, resumeUpload, error } =
|
|
139
|
-
typeof spokenSummary
|
|
140
|
-
>(WORKFLOW, { api });
|
|
135
|
+
const { submitForm, run, pending, upload, pauseUpload, resumeUpload, error, startedHere } =
|
|
136
|
+
useWorkflowSubmit<typeof spokenSummary>(WORKFLOW, { api });
|
|
141
137
|
const output = run?.status === "completed" ? run.output : undefined;
|
|
142
138
|
// `useDownloadUrl` is the SDK's: the byte route takes the agent's bearer, so the
|
|
143
139
|
// bytes have to be FETCHED and handed to the element as an object URL — and the
|
|
@@ -153,14 +149,7 @@ export function App() {
|
|
|
153
149
|
</p>
|
|
154
150
|
</header>
|
|
155
151
|
|
|
156
|
-
<Form
|
|
157
|
-
onSubmit={(values) => {
|
|
158
|
-
setStartedHere(true);
|
|
159
|
-
return submitForm(values);
|
|
160
|
-
}}
|
|
161
|
-
error={error}
|
|
162
|
-
className="flex flex-col gap-4"
|
|
163
|
-
>
|
|
152
|
+
<Form onSubmit={(values) => submitForm(values)} error={error} className="flex flex-col gap-4">
|
|
164
153
|
{/* Every control, from the workflow's own input schema. See the module doc. */}
|
|
165
154
|
<WorkflowFields workflow={WORKFLOW} />
|
|
166
155
|
<SubmitButton pending={pending} pendingLabel="Working…">
|
|
@@ -180,25 +169,27 @@ export function App() {
|
|
|
180
169
|
nothing for `<WorkflowProgress>` to read. */}
|
|
181
170
|
<UploadProgressBar upload={upload} onPause={pauseUpload} onResume={resumeUpload} />
|
|
182
171
|
|
|
183
|
-
{/* What the run itself says, from `
|
|
172
|
+
{/* What the run itself says, from `stepReport()` in the workflow's steps. */}
|
|
184
173
|
<WorkflowProgress runId={run?.runId} api={api} />
|
|
185
174
|
|
|
186
|
-
{
|
|
175
|
+
{/* `role="alert"`, the same contract `<Form>` gives the submit error: this
|
|
176
|
+
is the outcome the reader waited minutes for. */}
|
|
177
|
+
{run?.status === "failed" && (
|
|
178
|
+
<p role="alert" className="text-red-600">
|
|
179
|
+
That one failed: {run.error}
|
|
180
|
+
</p>
|
|
181
|
+
)}
|
|
187
182
|
|
|
188
183
|
{output !== undefined && (
|
|
189
184
|
<article className="flex flex-col gap-5">
|
|
190
185
|
<div className="flex flex-col gap-1">
|
|
191
186
|
<h2 className="text-xl">{output.headline}</h2>
|
|
192
|
-
<
|
|
193
|
-
{output.source
|
|
194
|
-
|
|
187
|
+
<Facts
|
|
188
|
+
items={[output.source, formatDuration(output.durationMs), `${output.words} words`]}
|
|
189
|
+
/>
|
|
195
190
|
</div>
|
|
196
191
|
|
|
197
|
-
<
|
|
198
|
-
{output.points.map((point) => (
|
|
199
|
-
<li key={point}>{point}</li>
|
|
200
|
-
))}
|
|
201
|
-
</ul>
|
|
192
|
+
<BulletList items={output.points} />
|
|
202
193
|
|
|
203
194
|
<section className="flex flex-col gap-2">
|
|
204
195
|
<h3 className="text-sm font-medium opacity-70">
|
|
@@ -206,11 +197,13 @@ export function App() {
|
|
|
206
197
|
</h3>
|
|
207
198
|
{audio.pending && <p className="text-sm opacity-70">Fetching the audio…</p>}
|
|
208
199
|
{audio.error !== undefined && (
|
|
209
|
-
<p className="text-red-600">
|
|
200
|
+
<p role="alert" className="text-red-600">
|
|
201
|
+
Could not load the audio: {audio.error}
|
|
202
|
+
</p>
|
|
210
203
|
)}
|
|
211
204
|
{audio.url !== undefined && (
|
|
212
205
|
<>
|
|
213
|
-
<audio controls src={audio.url} className="w-full">
|
|
206
|
+
<audio aria-label="Summary read aloud" controls src={audio.url} className="w-full">
|
|
214
207
|
{/* A real caption track, not a suppression: the summary was
|
|
215
208
|
written before it was spoken, so the words are already
|
|
216
209
|
here and one cue spanning the clip is an honest
|
|
@@ -244,4 +237,4 @@ export function App() {
|
|
|
244
237
|
);
|
|
245
238
|
}
|
|
246
239
|
|
|
247
|
-
|
|
240
|
+
mountPage({ name: "Spoken Summary", component: App });
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* - **`stepSpeak`** synthesizes from inside a step. The session TTS surface
|
|
19
19
|
* cannot: a `TtsSession` is an event stream wired into a live pipeline's
|
|
20
20
|
* playback, and a step has no turn to be part of and has to return a VALUE.
|
|
21
|
-
* - **`
|
|
21
|
+
* - **`stepWriteUpload`** puts that value somewhere. A run's OUTPUT is read back as
|
|
22
22
|
* JSON, so audio cannot travel in one — the same rule that keeps a
|
|
23
23
|
* recording's bytes out of a run's INPUT, arriving at the other end of the
|
|
24
24
|
* run. The bytes go to the store, the output carries the id, and the page
|
|
@@ -49,9 +49,9 @@
|
|
|
49
49
|
* expensive one.
|
|
50
50
|
*/
|
|
51
51
|
|
|
52
|
-
import type {
|
|
53
|
-
import {
|
|
54
|
-
import {
|
|
52
|
+
import type { WorkflowContext } from "@alexkroman1/aai";
|
|
53
|
+
import { stepReport, stepSpeak, stepWriteUpload, TRANSCRIBE_API } from "@alexkroman1/aai/step";
|
|
54
|
+
import { stepGenerateJsonOrFail } from "@alexkroman1/aai/step-errors";
|
|
55
55
|
import { countWords, omitUndefined } from "@alexkroman1/aai/utils";
|
|
56
56
|
// ERASED at build time, so the body can name the schema's own output type without
|
|
57
57
|
// a runtime cycle back through `agent.ts` — the same mechanism `client.tsx` uses
|
|
@@ -128,7 +128,7 @@ export type SpokenSummary = {
|
|
|
128
128
|
/** Transcribe a recording, summarize it, and read the summary back. */
|
|
129
129
|
export async function spokenSummaryFlow(
|
|
130
130
|
input: WorkflowInputOf<typeof spokenSummary>,
|
|
131
|
-
ctx:
|
|
131
|
+
ctx: WorkflowContext,
|
|
132
132
|
): Promise<SpokenSummary> {
|
|
133
133
|
const transcript = await transcribe(input.recording, ctx);
|
|
134
134
|
const summary = await ctx.step("summarize", () => summarize(transcript.text));
|
|
@@ -157,7 +157,7 @@ export async function spokenSummaryFlow(
|
|
|
157
157
|
* re-derives exactly the same sequence. It takes the `ctx` for that reason: a
|
|
158
158
|
* helper that reaches the journal has to be handed the handle.
|
|
159
159
|
*/
|
|
160
|
-
async function transcribe(recording: string, ctx:
|
|
160
|
+
async function transcribe(recording: string, ctx: WorkflowContext): Promise<Transcript> {
|
|
161
161
|
// `maxAttempts: 6` was `uploadToProvider.maxRetries = 5` — five retries after
|
|
162
162
|
// the first attempt. It is the one step here worth extra patience: it streams
|
|
163
163
|
// the whole recording, so a transient failure is expensive to reach again.
|
|
@@ -187,8 +187,8 @@ async function transcribe(recording: string, ctx: WorkflowCtx): Promise<Transcri
|
|
|
187
187
|
export async function summarize(
|
|
188
188
|
text: string,
|
|
189
189
|
): Promise<{ headline: string; points: string[]; spoken: string }> {
|
|
190
|
-
await
|
|
191
|
-
const reply = await
|
|
190
|
+
await stepReport("Summarizing the transcript.");
|
|
191
|
+
const reply = await stepGenerateJsonOrFail(
|
|
192
192
|
"Summarize this transcript of a recording.\n\n" +
|
|
193
193
|
"Answer with JSON only, in this shape:\n" +
|
|
194
194
|
`{"headline": "...", "points": ["..."], "spoken": "..."}\n\n` +
|
|
@@ -204,7 +204,7 @@ export async function summarize(
|
|
|
204
204
|
system: "You summarize recordings. You answer with JSON and nothing else.",
|
|
205
205
|
schema: SummaryReply,
|
|
206
206
|
},
|
|
207
|
-
// The `
|
|
207
|
+
// The `OrFail` caller is `stepGenerateJson` plus `throwStepError`, which
|
|
208
208
|
// reads the gateway's own status: a 429 is worth another attempt and a 400
|
|
209
209
|
// is not, and that is what tells the DevKit which.
|
|
210
210
|
);
|
|
@@ -226,7 +226,7 @@ export async function speak(
|
|
|
226
226
|
voice?: string,
|
|
227
227
|
): Promise<{ audio: string; durationMs: number }> {
|
|
228
228
|
const spoken = await stepSpeak(script, omitUndefined({ voice }));
|
|
229
|
-
const stored = await
|
|
229
|
+
const stored = await stepWriteUpload(spoken.audio, {
|
|
230
230
|
// Named, because this is what a person sees on the download link rather
|
|
231
231
|
// than an opaque id — and typed, because the byte route serves the type it
|
|
232
232
|
// was given and a browser will not play a file it was handed as bytes.
|
|
@@ -234,7 +234,7 @@ export async function speak(
|
|
|
234
234
|
type: "audio/wav",
|
|
235
235
|
});
|
|
236
236
|
|
|
237
|
-
await
|
|
237
|
+
await stepReport(
|
|
238
238
|
`Recorded a ${Math.round(spoken.durationMs / 1000)}s summary in ${spoken.voice}'s voice.`,
|
|
239
239
|
);
|
|
240
240
|
return { audio: stored.id, durationMs: spoken.durationMs };
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* **Every one of them is four lines, because the SDK owns the endpoint.**
|
|
14
14
|
* `stepTranscribeUpload` / `stepTranscribeSubmit` / `stepTranscribePoll` on
|
|
15
|
-
* `@alexkroman1/aai/step` — reached here through their `*
|
|
15
|
+
* `@alexkroman1/aai/step` — reached here through their `*OrFail` callers on
|
|
16
16
|
* `@alexkroman1/aai/step-errors` — carry the URL, the raw-key auth, the windowed
|
|
17
17
|
* streaming upload, the PLURAL `speech_models` field and the failure
|
|
18
18
|
* classification — all of which this file used to spell out, and all of which
|
|
@@ -40,11 +40,11 @@
|
|
|
40
40
|
* the one leg that should be as boring as possible.
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
|
-
import {
|
|
43
|
+
import { stepReport, stepUploadInfo } from "@alexkroman1/aai/step";
|
|
44
44
|
import {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
stepTranscribePollOrFail,
|
|
46
|
+
stepTranscribeSubmitOrFail,
|
|
47
|
+
stepTranscribeUploadOrFail,
|
|
48
48
|
} from "@alexkroman1/aai/step-errors";
|
|
49
49
|
import { countWords, formatBytes } from "@alexkroman1/aai/utils";
|
|
50
50
|
|
|
@@ -85,24 +85,24 @@ export type Transcript = {
|
|
|
85
85
|
* expires before the next step runs; that costs one fresh upload, once, instead
|
|
86
86
|
* of five.
|
|
87
87
|
*
|
|
88
|
-
* The `
|
|
88
|
+
* The `OrFail` callers on `@alexkroman1/aai/step-errors` are the SDK's own
|
|
89
89
|
* `stepTranscribe*` plus `throwStepError` and nothing else, which is what turns
|
|
90
90
|
* the SDK's `TranscribeError` into the DevKit's verdict — a missing key and a
|
|
91
91
|
* 400 stop, a 429 waits as long as the service asked. Every step here ends the
|
|
92
92
|
* same way for the same reason.
|
|
93
93
|
*/
|
|
94
94
|
export async function uploadToProvider(uploadId: string): Promise<{ audioUrl: string }> {
|
|
95
|
-
const stored = await
|
|
96
|
-
await
|
|
95
|
+
const stored = await stepUploadInfo(uploadId);
|
|
96
|
+
await stepReport(
|
|
97
97
|
`Uploading ${stored.name || uploadId} (${formatBytes(stored.size)}) for transcription.`,
|
|
98
98
|
);
|
|
99
|
-
return await
|
|
99
|
+
return await stepTranscribeUploadOrFail(uploadId);
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
/** Create the transcription job, and answer with the id that outlives this run. */
|
|
103
103
|
export async function createJob(audioUrl: string): Promise<{ id: string }> {
|
|
104
|
-
const job = await
|
|
105
|
-
await
|
|
104
|
+
const job = await stepTranscribeSubmitOrFail(audioUrl);
|
|
105
|
+
await stepReport(`Transcribing — job ${job.id}.`);
|
|
106
106
|
return job;
|
|
107
107
|
}
|
|
108
108
|
|
|
@@ -119,11 +119,11 @@ export async function pollTranscript(
|
|
|
119
119
|
uploadId: string,
|
|
120
120
|
id: string,
|
|
121
121
|
): Promise<{ done: false } | { done: true; transcript: Transcript }> {
|
|
122
|
-
const progress = await
|
|
122
|
+
const progress = await stepTranscribePollOrFail(id);
|
|
123
123
|
if (!progress.done) return { done: false };
|
|
124
124
|
|
|
125
|
-
const stored = await
|
|
126
|
-
await
|
|
125
|
+
const stored = await stepUploadInfo(uploadId);
|
|
126
|
+
await stepReport(`Transcribed ${countWords(progress.transcript.text)} words.`);
|
|
127
127
|
return {
|
|
128
128
|
done: true,
|
|
129
129
|
transcript: {
|
|
@@ -24,4 +24,12 @@ export default agent({
|
|
|
24
24
|
// caller's callback number, and only its reference crosses to the browser.
|
|
25
25
|
syncState: supportProjection,
|
|
26
26
|
greeting: `${PRODUCT} support, you're through to the automated line. What's happened?`,
|
|
27
|
+
// A support line is a PHONE line, so this one declares the carrier its number
|
|
28
|
+
// is with. Nothing serves `WS /phone` without this — the route is an
|
|
29
|
+
// allow-list, and an agent that says nothing about carriers answers none — so
|
|
30
|
+
// point a Twilio number's webhook at the deployed agent's `/phone` and the
|
|
31
|
+
// call lands in an ordinary session. `true` admits every carrier the runtime
|
|
32
|
+
// ships a codec for; a list is the narrower statement, and it is the one to
|
|
33
|
+
// copy.
|
|
34
|
+
telephony: ["twilio"],
|
|
27
35
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { plural } from "@alexkroman1/aai/utils";
|
|
1
2
|
import "@alexkroman1/aai-ui/styles.css";
|
|
2
|
-
import { AutoScroll,
|
|
3
|
+
import { AutoScroll, mountClient, useAgentState } from "@alexkroman1/aai-ui";
|
|
3
4
|
import { PRODUCT, supportProjection } from "./shared.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -19,7 +20,7 @@ function TraceSidebar() {
|
|
|
19
20
|
<div>
|
|
20
21
|
<h3 className="text-sm font-bold uppercase tracking-wide opacity-60">{support.product}</h3>
|
|
21
22
|
<p className="text-xs opacity-50">
|
|
22
|
-
{support.asked.length}
|
|
23
|
+
{support.asked.length} {plural(support.asked.length, "question")} this call
|
|
23
24
|
{support.ticket ? ` · ticket ${support.ticket}` : ""}
|
|
24
25
|
</p>
|
|
25
26
|
</div>
|
|
@@ -97,7 +98,7 @@ function TraceSidebar() {
|
|
|
97
98
|
);
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
|
|
101
|
+
mountClient({
|
|
101
102
|
// Derived, not typed twice: `PRODUCT` comes off `knowledge.json`, which is
|
|
102
103
|
// what `agent.ts` names the agent and greets with. A knowledge base swapped
|
|
103
104
|
// for another product otherwise leaves the browser tab advertising the old
|
|
@@ -40,8 +40,6 @@ export const binaryScore = z.object({
|
|
|
40
40
|
reason: z.string().max(200).describe("One short sentence of justification"),
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
export type BinaryScore = z.infer<typeof binaryScore>;
|
|
44
|
-
|
|
45
43
|
/** Their `retrieval_grader`: deliberately NOT a stringent test. */
|
|
46
44
|
export const DOC_GRADER_SYSTEM = [
|
|
47
45
|
"You are a grader assessing whether a retrieved support document is relevant",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
|
|
21
21
|
import { readdir } from "node:fs/promises";
|
|
22
22
|
import { tmpdir } from "node:os";
|
|
23
|
-
import {
|
|
23
|
+
import { stepReadUpload, type UploadRange } from "@alexkroman1/aai/step";
|
|
24
24
|
import { FatalError, RetryableError } from "@alexkroman1/aai/step-errors";
|
|
25
|
-
import {
|
|
25
|
+
import { createWorkflowContext } from "@alexkroman1/aai/testing";
|
|
26
26
|
import {
|
|
27
27
|
installStubReporter,
|
|
28
28
|
installStubStepFetch,
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
requestFormat,
|
|
42
42
|
} from "./workflows/downsample.ts";
|
|
43
43
|
import { cuttable, heavierThanNormalized, normalizeRecording } from "./workflows/normalize.ts";
|
|
44
|
+
import { stitchChunks, stitchTranscript, TRANSCRIPT_STREAM } from "./workflows/stitch.ts";
|
|
44
45
|
import {
|
|
45
46
|
expectedSegments,
|
|
46
47
|
nextPollDelay,
|
|
@@ -54,9 +55,6 @@ import {
|
|
|
54
55
|
import {
|
|
55
56
|
mergeTranscript,
|
|
56
57
|
splitRecording,
|
|
57
|
-
stitchChunks,
|
|
58
|
-
stitchTranscript,
|
|
59
|
-
TRANSCRIPT_STREAM,
|
|
60
58
|
type Transcript,
|
|
61
59
|
transcribeFlow,
|
|
62
60
|
transcribeSegment,
|
|
@@ -88,9 +86,9 @@ const UPLOAD_ID = "upl_test";
|
|
|
88
86
|
const STARTED_AT = 1_000_000;
|
|
89
87
|
|
|
90
88
|
/**
|
|
91
|
-
* Publish one in-memory upload, the way `
|
|
89
|
+
* Publish one in-memory upload, the way `createRuntimeServer` publishes a real store.
|
|
92
90
|
*
|
|
93
|
-
* This is the seam that makes a step testable at all: `
|
|
91
|
+
* This is the seam that makes a step testable at all: `stepReadUpload` reads a
|
|
94
92
|
* process-wide slot rather than dialling anything, so a spec supplies its own
|
|
95
93
|
* bytes with no server, no database and no HTTP.
|
|
96
94
|
*/
|
|
@@ -898,7 +896,7 @@ describe("transcribeSegment", () => {
|
|
|
898
896
|
// What makes the run's answer streamable rather than only its narration: the
|
|
899
897
|
// page stitches whatever has arrived, so the transcript renders growing
|
|
900
898
|
// instead of appearing when the last segment does. The reporter is the SDK's
|
|
901
|
-
// published slot, which is the same seam `
|
|
899
|
+
// published slot, which is the same seam `stepReport()` goes through.
|
|
902
900
|
const reported = installStubReporter();
|
|
903
901
|
stubProvider();
|
|
904
902
|
|
|
@@ -964,7 +962,7 @@ describe("transcribeSegment", () => {
|
|
|
964
962
|
// `transcribeStreamFlow`'s own `transcribeSegment` call carries the same
|
|
965
963
|
// budget and is asserted with that flow, not here — this drives only
|
|
966
964
|
// `transcribeFlow`.
|
|
967
|
-
const ctx =
|
|
965
|
+
const ctx = createWorkflowContext({
|
|
968
966
|
runSteps: false,
|
|
969
967
|
results: {
|
|
970
968
|
normalizeRecording: { recording: UPLOAD_ID, converted: false },
|
|
@@ -1116,7 +1114,7 @@ describe("the streaming flow", () => {
|
|
|
1116
1114
|
|
|
1117
1115
|
test("segmentStored refuses a window that STRADDLES a hole", () => {
|
|
1118
1116
|
// A run is contiguous, so containment in one is the whole test — and it has to
|
|
1119
|
-
// be, because `
|
|
1117
|
+
// be, because `stepReadUpload` clamps to the run a read starts in. A segment
|
|
1120
1118
|
// spanning two runs would come back short and be transcribed as a fragment,
|
|
1121
1119
|
// which is a wrong transcript rather than a failed one.
|
|
1122
1120
|
const at = poll(
|
|
@@ -1191,12 +1189,12 @@ describe("the streaming flow", () => {
|
|
|
1191
1189
|
});
|
|
1192
1190
|
|
|
1193
1191
|
test("a segment reads SHORT rather than failing when its bytes have not landed", async () => {
|
|
1194
|
-
// The property the whole flow rests on, and it predates streaming: `
|
|
1192
|
+
// The property the whole flow rests on, and it predates streaming: `stepReadUpload`
|
|
1195
1193
|
// clamps its window to what is stored. So a body that asks slightly early gets
|
|
1196
1194
|
// what exists — which is why the body checks `end <= size` and can trust the
|
|
1197
1195
|
// clamp for the final segment of a file that came up short.
|
|
1198
1196
|
publishPartial(1000, 320_000);
|
|
1199
|
-
const slice = await
|
|
1197
|
+
const slice = await stepReadUpload(UPLOAD_ID, { start: 44, end: 44 + 320_000 });
|
|
1200
1198
|
expect(slice.bytes.length).toBe(1000);
|
|
1201
1199
|
expect(slice.end).toBe(44 + 1000);
|
|
1202
1200
|
});
|
|
@@ -1356,6 +1354,16 @@ describe("normalizing the recording", () => {
|
|
|
1356
1354
|
* later in `splitRecording` with a message about a header; a `timeout`
|
|
1357
1355
|
* classified as fatal is a run that gives up on work that would have finished.
|
|
1358
1356
|
*/
|
|
1357
|
+
// Point the toolchain at nothing, so this block really is what its doc says:
|
|
1358
|
+
// a unit test that spawns no binary. Without it a developer with ffmpeg
|
|
1359
|
+
// installed runs a different test from CI, which has none — and the one
|
|
1360
|
+
// assertion below that reaches `normalizeRecording` is about a reporter line
|
|
1361
|
+
// emitted BEFORE the spawn. `unstubEnvs` is repo-wide, so there is no teardown.
|
|
1362
|
+
beforeEach(() => {
|
|
1363
|
+
vi.stubEnv("AAI_FFMPEG_PATH", "/nonexistent/ffmpeg");
|
|
1364
|
+
vi.stubEnv("AAI_FFPROBE_PATH", "/nonexistent/ffprobe");
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1359
1367
|
test("a canonical WAV is cuttable, so the desk converts nothing", () => {
|
|
1360
1368
|
expect(cuttable(wavFile(MONO_16K, 32_000), 44 + 32_000)).toBe(true);
|
|
1361
1369
|
});
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
* `POST /workflows/uploads` (the browser does this for you: `uploads` below is
|
|
41
41
|
* what makes `<WorkflowFields>` render a file picker, and `useWorkflowSubmit`
|
|
42
42
|
* stores the file before starting the run), the input carries the returned id,
|
|
43
|
-
* and each step reads exactly the window it needs with `
|
|
43
|
+
* and each step reads exactly the window it needs with `stepReadUpload` — which is
|
|
44
44
|
* what keeps sixty steps from moving the same recording sixty times.
|
|
45
45
|
*
|
|
46
46
|
* None of that is this template's code. Uploads are the SDK's, for the reason
|
|
@@ -118,7 +118,7 @@ export const transcribe = workflow({
|
|
|
118
118
|
}),
|
|
119
119
|
// The one line that makes the form take a file: `<WorkflowFields>` renders a
|
|
120
120
|
// picker for this property, `useWorkflowSubmit` stores the chosen file, and
|
|
121
|
-
// the steps read it back with `
|
|
121
|
+
// the steps read it back with `stepReadUpload`.
|
|
122
122
|
uploads: ["recording"],
|
|
123
123
|
run: transcribeFlow,
|
|
124
124
|
});
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
* rather than a fourth option. A single request moves a file at one connection's
|
|
57
57
|
* throughput, which over any distance is a fraction of the link — so the SDK cuts
|
|
58
58
|
* the file into megabyte-aligned parts and sends four at once. Nothing about the
|
|
59
|
-
* workflow changes: the agent reassembles them, `
|
|
59
|
+
* workflow changes: the agent reassembles them, `stepReadUpload` reads the same
|
|
60
60
|
* windows, and the streaming flow still watches the file grow (what it polls is the
|
|
61
61
|
* CONTIGUOUS prefix, which is honest whether one connection or four are filling
|
|
62
62
|
* it).
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
* - **The page stitches with the RUN's own function.** `stitchChunks` is
|
|
84
84
|
* `workflows/stitch.ts`, imported by both, so the live text and the stored one
|
|
85
85
|
* cannot drift into two different transcripts of one recording.
|
|
86
|
-
* - **It is a SEPARATE stream from the progress log.** `
|
|
86
|
+
* - **It is a SEPARATE stream from the progress log.** `stepReport()`'s lines go to
|
|
87
87
|
* the default one, which `<WorkflowProgress>` renders verbatim; objects in
|
|
88
88
|
* there would come out as `[object Object]` between the sentences.
|
|
89
89
|
* - **The finished run wins.** Once `output` exists the panel renders that
|
|
@@ -193,7 +193,7 @@ import "@alexkroman1/aai-ui/styles.css";
|
|
|
193
193
|
import {
|
|
194
194
|
Form,
|
|
195
195
|
isTerminal,
|
|
196
|
-
|
|
196
|
+
mountPage,
|
|
197
197
|
SubmitButton,
|
|
198
198
|
UploadProgressBar,
|
|
199
199
|
useWorkflowRuns,
|
|
@@ -270,10 +270,6 @@ function TranscriptionDesk() {
|
|
|
270
270
|
// piece of state for all three hooks, because it describes the UPLOAD and every
|
|
271
271
|
// mode has one — see the module doc.
|
|
272
272
|
const [parallel, setParallel] = useState(true);
|
|
273
|
-
// Did THIS load press Transcribe? A reload cannot have, and it is the only way
|
|
274
|
-
// the page can tell "working on what you just sent" from "picking up where you
|
|
275
|
-
// left off" — the hooks report the run, not who asked for it.
|
|
276
|
-
const [startedHere, setStartedHere] = useState(false);
|
|
277
273
|
// ALL THREE hooks are called every render, because a hook may not be conditional —
|
|
278
274
|
// and that costs nothing here: none of them does anything until its `submit` is
|
|
279
275
|
// called, and `useWorkflowRun` underneath them holds no id until then either.
|
|
@@ -292,7 +288,13 @@ function TranscriptionDesk() {
|
|
|
292
288
|
// store — so it is the SAME hook against a different workflow. Only the streaming
|
|
293
289
|
// mode needs the other one, because only it needs the id before the bytes.
|
|
294
290
|
const active = mode === "streaming" ? streamed : mode === "batch" ? batched : stored;
|
|
295
|
-
|
|
291
|
+
// `startedHere` comes off the ACTIVE hook rather than being one flag for the
|
|
292
|
+
// page, and that is a fix rather than a tidy-up: a single piece of page state
|
|
293
|
+
// survived a mode switch, so pressing Transcribe in one mode and then
|
|
294
|
+
// switching made the other mode's panel claim the reader had started its run.
|
|
295
|
+
// Per-hook, switching modes shows the truth about the run being shown.
|
|
296
|
+
const { submitForm, run, upload, pending, error, reset, pauseUpload, resumeUpload, startedHere } =
|
|
297
|
+
active;
|
|
296
298
|
// History is per WORKFLOW, so the list follows the mode: two flows that produce
|
|
297
299
|
// the same output are still two different things to have run, and merging them
|
|
298
300
|
// would put a run under a heading that cannot explain it.
|
|
@@ -349,7 +351,6 @@ function TranscriptionDesk() {
|
|
|
349
351
|
<Form
|
|
350
352
|
onSubmit={(values) => {
|
|
351
353
|
total.start();
|
|
352
|
-
setStartedHere(true);
|
|
353
354
|
// Written at SUBMIT rather than on the radio, so the remembered mode
|
|
354
355
|
// is always the mode a run exists under — which is the only thing the
|
|
355
356
|
// next load can use it for.
|
|
@@ -369,8 +370,8 @@ function TranscriptionDesk() {
|
|
|
369
370
|
</Form>
|
|
370
371
|
|
|
371
372
|
<TotalLatency
|
|
372
|
-
|
|
373
|
-
|
|
373
|
+
startedAt={total.startedAt}
|
|
374
|
+
frozenMs={total.frozenMs}
|
|
374
375
|
runMs={run?.status === "completed" ? run.output.elapsedMs : undefined}
|
|
375
376
|
/>
|
|
376
377
|
|
|
@@ -394,8 +395,8 @@ function TranscriptionDesk() {
|
|
|
394
395
|
onClear={() => {
|
|
395
396
|
// A recovered run is dismissed as deliberately as one this load
|
|
396
397
|
// started: the lookup is a mount-time act, so `reset()` is not
|
|
397
|
-
// undone by a second one and Clear really does clear.
|
|
398
|
-
|
|
398
|
+
// undone by a second one and Clear really does clear. `reset()`
|
|
399
|
+
// also clears `startedHere`, which this page used to mirror here.
|
|
399
400
|
reset();
|
|
400
401
|
total.clear();
|
|
401
402
|
}}
|
|
@@ -500,4 +501,4 @@ function UploadPicker({
|
|
|
500
501
|
);
|
|
501
502
|
}
|
|
502
503
|
|
|
503
|
-
|
|
504
|
+
mountPage({ name: "Transcription Desk", component: TranscriptionDesk });
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* `client.tsx` down to the page's shape:
|
|
15
15
|
*
|
|
16
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 `
|
|
17
|
+
* can be tested from a `client.tsx`. That file ends in a `mountPage()` call and
|
|
18
18
|
* imports a stylesheet, and this package's suites have no DOM at all: the
|
|
19
19
|
* vitest `include` matches `.test.ts` and not `.test.tsx`, and the scaffold
|
|
20
20
|
* declares no React testing library — so a `client.test.tsx` would be
|