@alexkroman1/aai-cli 6.5.1 → 6.7.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.
|
@@ -9,3 +9,24 @@
|
|
|
9
9
|
# Required only if your agent enables the `web_search` builtin
|
|
10
10
|
# (free key: https://brave.com/search/api/):
|
|
11
11
|
# BRAVE_API_KEY=
|
|
12
|
+
|
|
13
|
+
# --- Durable workflows and uploads -----------------------------------------
|
|
14
|
+
#
|
|
15
|
+
# A durable run needs a database; `api.upload(file)` needs a database AND
|
|
16
|
+
# somewhere to put the bytes. A DEPLOYED agent gets both from the platform
|
|
17
|
+
# (`aai storage enable`), so these four are the LOCAL equivalents and none of
|
|
18
|
+
# them is needed by an agent that has no workflow.
|
|
19
|
+
#
|
|
20
|
+
# All three storage keys or none: two of three resolves to nothing and refuses
|
|
21
|
+
# by name rather than half-working. Nothing creates the bucket for you — a
|
|
22
|
+
# bucket is dashboard state, not a migration.
|
|
23
|
+
#
|
|
24
|
+
# From the Supabase CLI: `supabase start`, then `supabase status -o env` for
|
|
25
|
+
# API_URL and SERVICE_ROLE_KEY. The local stack declares one bucket, `blobs`,
|
|
26
|
+
# and uploads land under an `uploads/` prefix inside it — the same layout
|
|
27
|
+
# production uses beside its `blobs/<sha256>` deploy artifacts.
|
|
28
|
+
#
|
|
29
|
+
# DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
|
30
|
+
# AAI_UPLOAD_STORAGE_URL=http://127.0.0.1:54321
|
|
31
|
+
# AAI_UPLOAD_STORAGE_KEY=<SERVICE_ROLE_KEY>
|
|
32
|
+
# AAI_UPLOAD_STORAGE_BUCKET=blobs
|
package/dist/scaffold/CLAUDE.md
CHANGED
|
@@ -1155,6 +1155,29 @@ A query returning more than 1000 rows throws — always bound reads with
|
|
|
1155
1155
|
preview and published agents, each with its own schema)
|
|
1156
1156
|
- `aai dev`: set `DATABASE_URL` in the project `.env`
|
|
1157
1157
|
|
|
1158
|
+
**A workflow UPLOAD needs one more thing locally: somewhere to put the bytes.**
|
|
1159
|
+
A deployed agent gets it from the platform; under `aai dev` the bytes go to a
|
|
1160
|
+
bucket you point it at, and the Supabase CLI prints the two values
|
|
1161
|
+
(`supabase start`, then `supabase status -o env`):
|
|
1162
|
+
|
|
1163
|
+
```sh
|
|
1164
|
+
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
|
1165
|
+
AAI_UPLOAD_STORAGE_URL=http://127.0.0.1:54321
|
|
1166
|
+
AAI_UPLOAD_STORAGE_KEY=<SERVICE_ROLE_KEY>
|
|
1167
|
+
AAI_UPLOAD_STORAGE_BUCKET=blobs
|
|
1168
|
+
```
|
|
1169
|
+
|
|
1170
|
+
`blobs`, not `uploads`: that is the one bucket the local stack declares
|
|
1171
|
+
(`supabase/config.toml`, applied by `supabase start`), and uploads land under an
|
|
1172
|
+
`uploads/` PREFIX inside it — the same layout production uses beside its
|
|
1173
|
+
`blobs/<sha256>` deploy artifacts. Nothing creates a bucket for you.
|
|
1174
|
+
|
|
1175
|
+
`.env.example` in a scaffolded project carries this block commented out.
|
|
1176
|
+
|
|
1177
|
+
Without both halves `api.upload(file)` fails naming the one that is missing —
|
|
1178
|
+
never quietly into a directory, which is what it used to do and then lose by the
|
|
1179
|
+
time a resumed run read it.
|
|
1180
|
+
|
|
1158
1181
|
Create tables lazily from tool code and upsert with `on conflict`:
|
|
1159
1182
|
|
|
1160
1183
|
```ts no-check
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
"publish:agent": "aai publish"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@alexkroman1/aai": "^6.
|
|
17
|
-
"@alexkroman1/aai-ui": "^6.
|
|
16
|
+
"@alexkroman1/aai": "^6.7.0",
|
|
17
|
+
"@alexkroman1/aai-ui": "^6.7.0",
|
|
18
18
|
"@workflow/world-postgres": "4.3.3",
|
|
19
19
|
"react": "^19.2.8",
|
|
20
20
|
"react-dom": "^19.2.8",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"zod": "^4.4.3"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@alexkroman1/aai-cli": "^6.
|
|
26
|
+
"@alexkroman1/aai-cli": "^6.7.0",
|
|
27
27
|
"@tailwindcss/vite": "^4.3.3",
|
|
28
28
|
"@types/node": "^26.2.0",
|
|
29
29
|
"@types/react": "^19.2.18",
|
|
@@ -61,11 +61,13 @@
|
|
|
61
61
|
* CONTIGUOUS prefix, which is honest whether one connection or four are filling
|
|
62
62
|
* it).
|
|
63
63
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* is
|
|
64
|
+
* The SDK does this by DEFAULT, so the checkbox is an opt-OUT rather than an
|
|
65
|
+
* opt-in — and it is here for the reason the mode radios are: this is the template
|
|
66
|
+
* where a reader runs both over the same recording and sees what each costs. It
|
|
67
|
+
* also degrades on its own — a small file, or an agent deployed before the
|
|
68
|
+
* `/parts` routes existed, sends the single request instead — so leaving it on is
|
|
69
|
+
* safe, and turning it off costs retries as well as speed (a single request is the
|
|
70
|
+
* one upload path that cannot be re-sent).
|
|
69
71
|
*
|
|
70
72
|
* ## The transcript ARRIVES, rather than appearing at the end
|
|
71
73
|
*
|
|
@@ -104,6 +106,22 @@
|
|
|
104
106
|
* whose schema has an object or array property writes those fields itself, in
|
|
105
107
|
* the same `<Form>` — every field in `@alexkroman1/aai-ui` is a plain named
|
|
106
108
|
* control, so declared and hand-written ones mix freely.
|
|
109
|
+
*
|
|
110
|
+
* ## Two waits, ONE number
|
|
111
|
+
*
|
|
112
|
+
* The two bars describe the two stretches separately, and neither answers the
|
|
113
|
+
* question a reader comparing the three modes is actually asking: how long from
|
|
114
|
+
* pressing Transcribe to having a transcript. Nothing on the server can answer it
|
|
115
|
+
* either — `output.elapsedMs` is the RUN's own wall clock, so it starts after the
|
|
116
|
+
* bytes are stored in two of the three modes and misses the whole upload, which is
|
|
117
|
+
* most of the wait on a long file over a slow link. Only the browser holds both
|
|
118
|
+
* ends, so `useTotalLatency` is a stopwatch here: started by the submit, ticking
|
|
119
|
+
* across the upload and the run alike, and frozen the moment the run settles.
|
|
120
|
+
*
|
|
121
|
+
* `<TotalLatency>` also prints the SPLIT once the run reports its own elapsed —
|
|
122
|
+
* before the run and inside it — because the two numbers on screen otherwise
|
|
123
|
+
* disagree with no way to see why, and their difference is exactly what picking a
|
|
124
|
+
* mode or unchecking `parallel` moves.
|
|
107
125
|
*/
|
|
108
126
|
|
|
109
127
|
import "@alexkroman1/aai-ui/styles.css";
|
|
@@ -122,7 +140,7 @@ import {
|
|
|
122
140
|
WorkflowProgress,
|
|
123
141
|
type WorkflowRun,
|
|
124
142
|
} from "@alexkroman1/aai-ui";
|
|
125
|
-
import { useEffect, useMemo, useState } from "react";
|
|
143
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
126
144
|
import type { transcribe } from "./agent.ts";
|
|
127
145
|
import { ApiHelp } from "./api-help.tsx";
|
|
128
146
|
import {
|
|
@@ -185,6 +203,132 @@ const MODES: readonly { mode: Mode; label: string; note: string }[] = [
|
|
|
185
203
|
/** Most past runs the history list shows. */
|
|
186
204
|
const HISTORY_LIMIT = 10;
|
|
187
205
|
|
|
206
|
+
/**
|
|
207
|
+
* How often the running stopwatch re-renders.
|
|
208
|
+
*
|
|
209
|
+
* Under a second, so the displayed seconds turn over promptly rather than up to a
|
|
210
|
+
* second late; nothing reads this value, since the elapsed time is measured from
|
|
211
|
+
* the clock at render (see {@link useTotalLatency}).
|
|
212
|
+
*/
|
|
213
|
+
const STOPWATCH_TICK_MS = 250;
|
|
214
|
+
|
|
215
|
+
/** What {@link useTotalLatency} reports. */
|
|
216
|
+
type TotalLatency = {
|
|
217
|
+
/**
|
|
218
|
+
* Milliseconds since the submit — ticking while the submission is in flight,
|
|
219
|
+
* frozen at the finish, and undefined before the first one.
|
|
220
|
+
*/
|
|
221
|
+
elapsedMs: number | undefined;
|
|
222
|
+
/** Whether the clock is still running, which is what makes the label honest. */
|
|
223
|
+
running: boolean;
|
|
224
|
+
/** Start (or restart) the clock. Called from the form's own submit handler. */
|
|
225
|
+
start: () => void;
|
|
226
|
+
/** Drop it, for a panel that no longer describes the submission it timed. */
|
|
227
|
+
clear: () => void;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Wall clock from the submit to the finish, across both waits.
|
|
232
|
+
*
|
|
233
|
+
* `inFlight` is the submission's own `pending` — true from `submit()` until the run
|
|
234
|
+
* reaches a terminal status — so the clock covers the upload, the run, and the
|
|
235
|
+
* gap between them, which is the whole of what a reader waits for and is the one
|
|
236
|
+
* measurement no server-side number can make.
|
|
237
|
+
*
|
|
238
|
+
* Two details it would be easy to get wrong:
|
|
239
|
+
*
|
|
240
|
+
* - **The interval re-renders; it does not accumulate.** The elapsed time is read
|
|
241
|
+
* from the clock at render, so a tick the tab throttled or dropped cannot make
|
|
242
|
+
* the number lag behind real time.
|
|
243
|
+
* - **`performance.now()`, not `Date.now()`.** It is monotonic, so a clock
|
|
244
|
+
* correction (NTP, a laptop waking up) cannot make a transcription look
|
|
245
|
+
* instant — or negative.
|
|
246
|
+
*/
|
|
247
|
+
function useTotalLatency(inFlight: boolean): TotalLatency {
|
|
248
|
+
const [startedAt, setStartedAt] = useState<number | undefined>(undefined);
|
|
249
|
+
const [frozenMs, setFrozenMs] = useState<number | undefined>(undefined);
|
|
250
|
+
// Re-render trigger only — see the doc above.
|
|
251
|
+
const [, tick] = useState(0);
|
|
252
|
+
// Whether `inFlight` has been seen true since the last `start()`. Without it,
|
|
253
|
+
// a start that lands one render before the submission reports itself in flight
|
|
254
|
+
// would freeze the clock at zero instead of running it.
|
|
255
|
+
const began = useRef(false);
|
|
256
|
+
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
if (startedAt === undefined || frozenMs !== undefined) return;
|
|
259
|
+
if (inFlight) {
|
|
260
|
+
began.current = true;
|
|
261
|
+
const id = setInterval(() => tick((n) => n + 1), STOPWATCH_TICK_MS);
|
|
262
|
+
return () => clearInterval(id);
|
|
263
|
+
}
|
|
264
|
+
// Measured here rather than at render, so the frozen number is the one at the
|
|
265
|
+
// moment the run settled rather than whenever this page next drew.
|
|
266
|
+
if (began.current) setFrozenMs(performance.now() - startedAt);
|
|
267
|
+
}, [startedAt, frozenMs, inFlight]);
|
|
268
|
+
|
|
269
|
+
const start = useCallback(() => {
|
|
270
|
+
began.current = false;
|
|
271
|
+
setFrozenMs(undefined);
|
|
272
|
+
setStartedAt(performance.now());
|
|
273
|
+
}, []);
|
|
274
|
+
|
|
275
|
+
const clear = useCallback(() => {
|
|
276
|
+
began.current = false;
|
|
277
|
+
setStartedAt(undefined);
|
|
278
|
+
setFrozenMs(undefined);
|
|
279
|
+
}, []);
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
elapsedMs: frozenMs ?? (startedAt === undefined ? undefined : performance.now() - startedAt),
|
|
283
|
+
running: startedAt !== undefined && frozenMs === undefined,
|
|
284
|
+
start,
|
|
285
|
+
clear,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The one number the two bars cannot give: click to transcript.
|
|
291
|
+
*
|
|
292
|
+
* Rendered above the run panel rather than inside it, because the stretch it
|
|
293
|
+
* covers starts before there IS a run — in two of the three modes the run does
|
|
294
|
+
* not exist until the upload finishes, so a clock living in the panel would
|
|
295
|
+
* appear only after the wait it is supposed to be timing.
|
|
296
|
+
*
|
|
297
|
+
* `runMs` is the run's own elapsed, once it reports one. The remainder is
|
|
298
|
+
* everything the run could not see: storing the file (or, in streaming mode,
|
|
299
|
+
* minting the upload id), the `POST` that starts the run, and the poll that
|
|
300
|
+
* notices it finished. Clamped at zero, because the two numbers come from two
|
|
301
|
+
* different clocks on two different machines and a few milliseconds the wrong way
|
|
302
|
+
* would otherwise print a negative.
|
|
303
|
+
*/
|
|
304
|
+
function TotalLatency({
|
|
305
|
+
elapsedMs,
|
|
306
|
+
running,
|
|
307
|
+
runMs,
|
|
308
|
+
}: {
|
|
309
|
+
elapsedMs: number | undefined;
|
|
310
|
+
running: boolean;
|
|
311
|
+
runMs: number | undefined;
|
|
312
|
+
}) {
|
|
313
|
+
if (elapsedMs === undefined) return null;
|
|
314
|
+
const outside = runMs === undefined ? undefined : Math.max(0, elapsedMs - runMs);
|
|
315
|
+
return (
|
|
316
|
+
<section className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 rounded-md border px-5 py-3">
|
|
317
|
+
<h2 className="text-sm font-medium uppercase tracking-[1.2px]">
|
|
318
|
+
{running ? "Elapsed" : "Total latency"}
|
|
319
|
+
</h2>
|
|
320
|
+
<span className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
|
321
|
+
<span className="text-sm tabular-nums">{duration(elapsedMs)}</span>
|
|
322
|
+
{runMs !== undefined && outside !== undefined && (
|
|
323
|
+
<span className="text-xs tabular-nums opacity-60">
|
|
324
|
+
{duration(outside)} before the run · {duration(runMs)} inside it
|
|
325
|
+
</span>
|
|
326
|
+
)}
|
|
327
|
+
</span>
|
|
328
|
+
</section>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
188
332
|
function TranscriptionDesk() {
|
|
189
333
|
const [mode, setMode] = useState<Mode>("streaming");
|
|
190
334
|
// Whether the browser cuts the recording up and sends the pieces at once. One
|
|
@@ -209,6 +353,8 @@ function TranscriptionDesk() {
|
|
|
209
353
|
// Which past run the reader is looking at, if any. Its own state rather than
|
|
210
354
|
// a route, because a workflow app is one page and a run id is not a place.
|
|
211
355
|
const [openId, setOpenId] = useState<string | undefined>(undefined);
|
|
356
|
+
// Click to transcript, measured here because only the browser sees both ends.
|
|
357
|
+
const total = useTotalLatency(pending);
|
|
212
358
|
|
|
213
359
|
// The list is read once and re-read on demand (see `useWorkflowRuns`), and
|
|
214
360
|
// this is the "on demand": the moment the run this page started settles, the
|
|
@@ -231,14 +377,34 @@ function TranscriptionDesk() {
|
|
|
231
377
|
</p>
|
|
232
378
|
</header>
|
|
233
379
|
|
|
234
|
-
|
|
380
|
+
{/* The clock goes with the mode: switching swaps `active` for another hook's
|
|
381
|
+
run, and a total measured over a different submission would be a number
|
|
382
|
+
for something the panel below is no longer showing. */}
|
|
383
|
+
<ModePicker
|
|
384
|
+
mode={mode}
|
|
385
|
+
onPick={(next) => {
|
|
386
|
+
setMode(next);
|
|
387
|
+
total.clear();
|
|
388
|
+
}}
|
|
389
|
+
disabled={pending}
|
|
390
|
+
/>
|
|
235
391
|
|
|
236
392
|
<UploadPicker parallel={parallel} onPick={setParallel} disabled={pending} />
|
|
237
393
|
|
|
238
394
|
{/* No mapping: the collected values already match the input schema. All three
|
|
239
395
|
workflows declare `recording` as an upload, so the same picker serves every
|
|
240
396
|
mode — how the bytes travel is not a question to ask a person. */}
|
|
241
|
-
|
|
397
|
+
{/* The clock starts HERE, which is as close to the press as a page can get:
|
|
398
|
+
`<Form>` calls this once the browser's own validation has passed and it has
|
|
399
|
+
read the controls, and an upload field contributes its `File` unread — so
|
|
400
|
+
what separates this line from the click is a microtask, not the file. */}
|
|
401
|
+
<Form
|
|
402
|
+
onSubmit={(values) => {
|
|
403
|
+
total.start();
|
|
404
|
+
return submit(values);
|
|
405
|
+
}}
|
|
406
|
+
error={error}
|
|
407
|
+
>
|
|
242
408
|
{/* The NAME, so the schema is fetched here rather than by this page. */}
|
|
243
409
|
<WorkflowFields workflow={WORKFLOWS[mode]} />
|
|
244
410
|
{/* Unguarded on purpose: it renders nothing until there are bytes in
|
|
@@ -247,7 +413,21 @@ function TranscriptionDesk() {
|
|
|
247
413
|
<SubmitButton pending={pending}>Transcribe</SubmitButton>
|
|
248
414
|
</Form>
|
|
249
415
|
|
|
250
|
-
|
|
416
|
+
<TotalLatency
|
|
417
|
+
elapsedMs={total.elapsedMs}
|
|
418
|
+
running={total.running}
|
|
419
|
+
runMs={run?.status === "completed" ? run.output.elapsedMs : undefined}
|
|
420
|
+
/>
|
|
421
|
+
|
|
422
|
+
{run && (
|
|
423
|
+
<RunPanel
|
|
424
|
+
run={run}
|
|
425
|
+
onClear={() => {
|
|
426
|
+
reset();
|
|
427
|
+
total.clear();
|
|
428
|
+
}}
|
|
429
|
+
/>
|
|
430
|
+
)}
|
|
251
431
|
|
|
252
432
|
<History
|
|
253
433
|
runs={history.runs}
|
|
@@ -342,7 +522,8 @@ function UploadPicker({
|
|
|
342
522
|
<span>Split the file across connections</span>
|
|
343
523
|
<span className="text-xs opacity-70">
|
|
344
524
|
Sends the recording as several parts at once instead of in one request, which is most of
|
|
345
|
-
the wait on a long file
|
|
525
|
+
the wait on a long file — and is the only upload a dropped connection can resume. Falls
|
|
526
|
+
back to the single request on a small one.
|
|
346
527
|
</span>
|
|
347
528
|
</span>
|
|
348
529
|
</label>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alexkroman1/aai-cli",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"aai": "bin.mjs"
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"p-timeout": "^7.0.1",
|
|
45
45
|
"vite": "^8.2.1",
|
|
46
46
|
"zod": "^4.4.3",
|
|
47
|
-
"@alexkroman1/aai": "6.
|
|
48
|
-
"@alexkroman1/aai-ui": "6.
|
|
47
|
+
"@alexkroman1/aai": "6.7.0",
|
|
48
|
+
"@alexkroman1/aai-ui": "6.7.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"playwright": "^1.62.1",
|