@alexkroman1/aai-cli 6.10.1 → 6.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/scaffold/CLAUDE.md +58 -0
- package/dist/scaffold/package.json +3 -3
- package/dist/scaffold/server.mjs +12 -3
- package/dist/scaffold/vite.config.ts +1 -1
- package/dist/templates/call-audit/agent.test.ts +965 -0
- package/dist/templates/call-audit/agent.ts +158 -0
- package/dist/templates/call-audit/client.tsx +235 -0
- package/dist/templates/call-audit/workflows/audit.ts +305 -0
- package/dist/templates/call-audit/workflows/ingest.ts +259 -0
- package/dist/templates/call-audit/workflows/media.ts +647 -0
- package/dist/templates/call-audit/workflows/summarize.ts +206 -0
- package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
- package/dist/templates/call-audit/workflows/temp-media.ts +138 -0
- package/dist/templates/recap-workflow/agent.test.ts +11 -3
- package/dist/templates/recap-workflow/workflows/recap.ts +19 -8
- package/dist/templates/spoken-summary/agent.test.ts +343 -0
- package/dist/templates/spoken-summary/agent.ts +142 -0
- package/dist/templates/spoken-summary/client.tsx +225 -0
- package/dist/templates/spoken-summary/workflows/summarize.ts +242 -0
- package/dist/templates/spoken-summary/workflows/transcribe.ts +145 -0
- package/dist/templates/transcription-workflow/agent.test.ts +241 -18
- package/dist/templates/transcription-workflow/agent.ts +20 -6
- package/dist/templates/transcription-workflow/workflows/batch.ts +75 -173
- package/dist/templates/transcription-workflow/workflows/normalize.ts +343 -0
- package/dist/templates/transcription-workflow/workflows/stream.ts +6 -4
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -14
- package/dist/templates/transcription-workflow/workflows/wav.ts +31 -0
- package/package.json +3 -3
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* A WORKFLOW APP that audits a recorded call, with **ffmpeg in the pipeline on
|
|
4
|
+
* both sides of the model.**
|
|
5
|
+
*
|
|
6
|
+
* ```text
|
|
7
|
+
* any recording → levelled PCM → cut at the pauses → transcript
|
|
8
|
+
* ffmpeg ffmpeg sync STT
|
|
9
|
+
*
|
|
10
|
+
* → headline, risks, actions → a script → an MP3
|
|
11
|
+
* LLM Gateway schema TTS + ffmpeg
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* ## Which template to read first
|
|
15
|
+
*
|
|
16
|
+
* This one is the deepest of the workflow apps, so it is the wrong place to start.
|
|
17
|
+
*
|
|
18
|
+
* - `link-digest` owns the SHAPE — `workflowApp()`, no session, no tools, a form
|
|
19
|
+
* that starts a run and a page that watches it.
|
|
20
|
+
* - `transcription-workflow` owns the FAN-OUT — why the sync transcription
|
|
21
|
+
* endpoint's 120-second cap forces one, and how a run survives dying on segment
|
|
22
|
+
* 27 of 60.
|
|
23
|
+
* - `spoken-summary` owns the audio ROUND TRIP — `stepSpeak`, `writeUpload`, and
|
|
24
|
+
* why a page needs `api.download` rather than a URL.
|
|
25
|
+
*
|
|
26
|
+
* **What this template adds is a decoder**, and everything downstream changes
|
|
27
|
+
* because of it. `workflows/audit.ts` carries the table comparing it against
|
|
28
|
+
* `transcription-workflow` row by row; the summary is that normalizing FIRST lets
|
|
29
|
+
* the desk cut a recording *in its pauses* instead of every 90 seconds, which
|
|
30
|
+
* deletes the segment overlap, the seam-matching stitcher, the WAV header parser
|
|
31
|
+
* and one of the two provider caps it had to plan against.
|
|
32
|
+
*
|
|
33
|
+
* ## Five ffmpeg jobs, and each one is a decision
|
|
34
|
+
*
|
|
35
|
+
* `workflows/media.ts` is where they are built, and it is the file to read: every
|
|
36
|
+
* argv is a pure function, so the argv is a value a spec asserts on rather than a
|
|
37
|
+
* string embedded in a step that spawns.
|
|
38
|
+
*
|
|
39
|
+
* | | what it does | why it is not obvious |
|
|
40
|
+
* | --- | --- | --- |
|
|
41
|
+
* | `ffprobe` | what the file WAS | on a temp file, because a pipe cannot seek an m4a's trailing index |
|
|
42
|
+
* | `loudnorm` pass 1 | measure five numbers | `-f null -`: decode everything, write nothing |
|
|
43
|
+
* | `loudnorm` pass 2 | apply them | one linear gain, so speech does not pump |
|
|
44
|
+
* | `silencedetect` | find every pause | same pass as above — a filter chain, not a second decode |
|
|
45
|
+
* | `libmp3lame` | master the summary | 4.3 MB of WAV becomes ~110 KB |
|
|
46
|
+
*
|
|
47
|
+
* The two analyses read their answers back by **different routes**, and that is the
|
|
48
|
+
* single most surprising thing in the template: loudness arrives on stderr (one
|
|
49
|
+
* block, printed last, so the SDK's capped stderr TAIL holds it) and the pauses
|
|
50
|
+
* arrive in a FILE (one event per pause, so a tail would silently drop the
|
|
51
|
+
* earliest ones and the desk would mis-cut only long recordings). `media.ts`'s
|
|
52
|
+
* module doc carries it.
|
|
53
|
+
*
|
|
54
|
+
* ## What it needs
|
|
55
|
+
*
|
|
56
|
+
* - **`ASSEMBLYAI_API_KEY` in the agent env** — `.env` under `aai dev`,
|
|
57
|
+
* `aai secret put ASSEMBLYAI_API_KEY` once deployed. `requiredEnv` below is what
|
|
58
|
+
* makes a deploy check for it rather than letting the first run find out. One key
|
|
59
|
+
* covers transcription, the model and the voice alike.
|
|
60
|
+
* - **Storage** (`aai storage enable`, Settings → Database in the studio, or
|
|
61
|
+
* `DATABASE_URL` under `aai dev`). REQUIRED, unlike most workflow apps: an
|
|
62
|
+
* upload's record is a row, and this desk both reads an upload and writes two.
|
|
63
|
+
* - **ffmpeg** — every deployed guest's image installs it (and `ffprobe` with it).
|
|
64
|
+
* Under `aai dev` it is whatever is on `PATH`, or what `AAI_FFMPEG_PATH` /
|
|
65
|
+
* `AAI_FFPROBE_PATH` name. That is the one place dev/prod parity is partial, so
|
|
66
|
+
* a missing binary is reported as an instruction rather than as
|
|
67
|
+
* `spawn ffmpeg ENOENT`.
|
|
68
|
+
*
|
|
69
|
+
* ## It is scriptable, which is the other half of having an API
|
|
70
|
+
*
|
|
71
|
+
* The page is one caller. Two requests do the same thing from a shell — upload,
|
|
72
|
+
* then start a run naming the id, with `wait` holding the request open:
|
|
73
|
+
*
|
|
74
|
+
* ```sh
|
|
75
|
+
* ID=$(curl -s -X POST "https://<your-agent>/workflows/uploads?name=call.m4a" \
|
|
76
|
+
* -H 'content-type: audio/mp4' --data-binary @call.m4a | jq -r .id)
|
|
77
|
+
*
|
|
78
|
+
* curl -X POST https://<your-agent>/workflows/runs \
|
|
79
|
+
* -H 'content-type: application/json' \
|
|
80
|
+
* -d "{\"workflow\":\"audit\",\"wait\":60000,\"input\":{\"recording\":\"$ID\"}}"
|
|
81
|
+
* ```
|
|
82
|
+
*
|
|
83
|
+
* The spoken audit comes back as an upload id in the run's output; the byte route
|
|
84
|
+
* (`GET /workflows/uploads/<id>`) takes the same bearer every other route does.
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
import { workflow, workflowApp } from "@alexkroman1/aai";
|
|
88
|
+
import { ASSEMBLYAI_TTS_DEFAULT_VOICE, ASSEMBLYAI_TTS_VOICES } from "@alexkroman1/aai/tts";
|
|
89
|
+
import { z } from "zod";
|
|
90
|
+
import { auditFlow } from "./workflows/audit.ts";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The voices the form offers.
|
|
94
|
+
*
|
|
95
|
+
* READ from the SDK's catalog rather than listed, because a wrong voice id is a
|
|
96
|
+
* SILENT failure — it is a free-form string the service rejects in band after the
|
|
97
|
+
* socket is open, so the synthesis simply produces nothing. Narrowed to the English
|
|
98
|
+
* ones because the audit is written in the transcript's language and the prompt does
|
|
99
|
+
* not translate; every voice in the catalog speaks exactly one.
|
|
100
|
+
*/
|
|
101
|
+
const VOICES = Object.entries(ASSEMBLYAI_TTS_VOICES)
|
|
102
|
+
.filter(([, spec]) => spec.language === "en")
|
|
103
|
+
.map(([id]) => id);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The same list as a TUPLE, which is what `z.enum` takes.
|
|
107
|
+
*
|
|
108
|
+
* Destructured rather than cast: a `.map` produces an array, and
|
|
109
|
+
* `as [string, ...string[]]` would be a template teaching a cast. The default covers
|
|
110
|
+
* the empty case honestly — a catalog with no English voice falls back to the SDK's
|
|
111
|
+
* own default rather than rendering a picker with no options.
|
|
112
|
+
*/
|
|
113
|
+
const [FIRST_VOICE = ASSEMBLYAI_TTS_DEFAULT_VOICE, ...OTHER_VOICES] = VOICES;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The declaration: schema, description, and the directive body.
|
|
117
|
+
*
|
|
118
|
+
* Exported so `WorkflowOutputOf<typeof audit>` names the output type in one place —
|
|
119
|
+
* including from `client.tsx`, where `import type` is erased and so bundles nothing
|
|
120
|
+
* server-side.
|
|
121
|
+
*/
|
|
122
|
+
export const audit = workflow({
|
|
123
|
+
description: "Level a call recording, transcribe it at its pauses, and audit what was said",
|
|
124
|
+
input: z.object({
|
|
125
|
+
// A plain string, because an upload id is what the run really receives. What
|
|
126
|
+
// makes it a file picker rather than a text box is the `uploads` line below.
|
|
127
|
+
//
|
|
128
|
+
// "Anything" is not marketing: the first step hands the file to ffmpeg, so the
|
|
129
|
+
// accepted set is ffmpeg's rather than this template's — a video's audio track
|
|
130
|
+
// included, since the conversion drops the video.
|
|
131
|
+
recording: z.string().describe("Any recording — WAV, MP3, M4A, or a video's audio track"),
|
|
132
|
+
// An enum, so the form renders a SELECT rather than a text box — which is the
|
|
133
|
+
// whole reason the list is derived above rather than left free-form. Optional,
|
|
134
|
+
// so the SDK's own default voice applies when nobody chooses.
|
|
135
|
+
voice: z
|
|
136
|
+
.enum([FIRST_VOICE, ...OTHER_VOICES])
|
|
137
|
+
.optional()
|
|
138
|
+
.describe("Voice to read the audit in"),
|
|
139
|
+
}),
|
|
140
|
+
// The one line that makes the form take a file: `<WorkflowFields>` renders a
|
|
141
|
+
// picker for this property, `useWorkflowSubmit` stores the chosen file, and the
|
|
142
|
+
// ingest step reads it back with `readUpload`.
|
|
143
|
+
uploads: ["recording"],
|
|
144
|
+
run: auditFlow,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
export default workflowApp({
|
|
148
|
+
name: "Call Audit",
|
|
149
|
+
workflows: { audit },
|
|
150
|
+
// Checked at deploy time, so a missing key is a warning naming it rather than a run
|
|
151
|
+
// that fails on its third step. A workflow app declares no providers, so this is the
|
|
152
|
+
// only thing that can name the credential its steps read.
|
|
153
|
+
//
|
|
154
|
+
// ffmpeg is NOT here and cannot be: `requiredEnv` checks the agent's environment,
|
|
155
|
+
// and a binary on `PATH` is not an environment variable. The deployed guest always
|
|
156
|
+
// has one; under `aai dev` the failure names its own remedy.
|
|
157
|
+
requiredEnv: ["ASSEMBLYAI_API_KEY"],
|
|
158
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* The page: a form, a progress log, the audit, and a player.
|
|
4
|
+
*
|
|
5
|
+
* `link-digest` shows these primitives raw, `transcription-workflow` shows the form
|
|
6
|
+
* layer in full, and `spoken-summary` shows how a page plays a file the RUN
|
|
7
|
+
* produced. None of that is restated here — this page is deliberately the least
|
|
8
|
+
* novel file in the template, because the subject is the pipeline.
|
|
9
|
+
*
|
|
10
|
+
* Two things it does add, and both are about being honest about the pipeline:
|
|
11
|
+
*
|
|
12
|
+
* - **The PIPELINE panel.** A reader cannot tell from a transcript whether the desk
|
|
13
|
+
* cut it in the pauses or fell back to cutting by arithmetic, and that difference
|
|
14
|
+
* is exactly what explains a mangled word at a seam. So `blindCuts` is rendered
|
|
15
|
+
* rather than hidden, alongside what the recording measured before levelling.
|
|
16
|
+
* - **`api.download`, not a URL.** The run's output carries an upload id. The
|
|
17
|
+
* obvious `<audio src={`/workflows/uploads/${id}`}>` is wrong in a way that only
|
|
18
|
+
* shows up after a deploy: the byte route takes the same `Authorization` header
|
|
19
|
+
* every other route does, and neither `<audio src>` nor `<a href>` can send one.
|
|
20
|
+
* So a page built on a URL works against `aai dev`, where there is no token, and
|
|
21
|
+
* 401s the moment the agent has one.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import "@alexkroman1/aai-ui/styles.css";
|
|
25
|
+
// ERASED at build time, so naming the agent's own type costs the browser bundle
|
|
26
|
+
// nothing — and it is what stops this file restating a shape `workflows/audit.ts`
|
|
27
|
+
// already declares.
|
|
28
|
+
import type { WorkflowOutputOf } from "@alexkroman1/aai";
|
|
29
|
+
import {
|
|
30
|
+
createWorkflowApi,
|
|
31
|
+
Form,
|
|
32
|
+
page,
|
|
33
|
+
SubmitButton,
|
|
34
|
+
UploadProgressBar,
|
|
35
|
+
useWorkflowSubmit,
|
|
36
|
+
WorkflowFields,
|
|
37
|
+
WorkflowProgress,
|
|
38
|
+
} from "@alexkroman1/aai-ui";
|
|
39
|
+
import { useEffect, useState } from "react";
|
|
40
|
+
import type { audit } from "./agent.ts";
|
|
41
|
+
|
|
42
|
+
/** What a completed run reports, derived from the workflow rather than restated. */
|
|
43
|
+
type Audit = WorkflowOutputOf<typeof audit>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The workflow's name, as a page starts a run by one.
|
|
47
|
+
*
|
|
48
|
+
* A rename in `agent.ts` is a runtime 400 rather than a compile error, which is why
|
|
49
|
+
* `agent.test.ts` pins this string.
|
|
50
|
+
*/
|
|
51
|
+
const WORKFLOW = "audit";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Hoisted out of the component deliberately.
|
|
55
|
+
*
|
|
56
|
+
* The hooks hold the client in a ref precisely so a fresh object per render cannot
|
|
57
|
+
* restart their watch, but building one in render is still a new `fetch` closure
|
|
58
|
+
* every time and reads as though it were free.
|
|
59
|
+
*/
|
|
60
|
+
const api = createWorkflowApi();
|
|
61
|
+
|
|
62
|
+
/** `4:09`, from the milliseconds a run reports. */
|
|
63
|
+
function duration(ms: number): string {
|
|
64
|
+
const total = Math.round(ms / 1000);
|
|
65
|
+
const minutes = Math.floor(total / 60);
|
|
66
|
+
return `${minutes}:${String(total % 60).padStart(2, "0")}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The finished run's audio, as something the browser will play.
|
|
71
|
+
*
|
|
72
|
+
* A hook rather than four lines in the component because the CLEANUP is the part
|
|
73
|
+
* worth keeping in one place: an object URL pins its blob for the life of the
|
|
74
|
+
* document, so it is revoked when the id changes and when the page goes away. The
|
|
75
|
+
* `cancelled` flag covers the other half — a second run settling while the first
|
|
76
|
+
* download is still in flight would otherwise set state from the stale one.
|
|
77
|
+
*/
|
|
78
|
+
function useAudioUrl(uploadId: string | undefined): { url?: string; error?: string } {
|
|
79
|
+
const [state, setState] = useState<{ url?: string; error?: string }>({});
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (uploadId === undefined) {
|
|
83
|
+
setState({});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
let cancelled = false;
|
|
87
|
+
let objectUrl: string | undefined;
|
|
88
|
+
api
|
|
89
|
+
.download(uploadId)
|
|
90
|
+
.then((blob) => {
|
|
91
|
+
if (cancelled) return;
|
|
92
|
+
objectUrl = URL.createObjectURL(blob);
|
|
93
|
+
setState({ url: objectUrl });
|
|
94
|
+
})
|
|
95
|
+
.catch((err: unknown) => {
|
|
96
|
+
if (!cancelled) setState({ error: err instanceof Error ? err.message : String(err) });
|
|
97
|
+
});
|
|
98
|
+
return () => {
|
|
99
|
+
cancelled = true;
|
|
100
|
+
if (objectUrl !== undefined) URL.revokeObjectURL(objectUrl);
|
|
101
|
+
};
|
|
102
|
+
}, [uploadId]);
|
|
103
|
+
|
|
104
|
+
return state;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One labelled number in the pipeline panel. */
|
|
108
|
+
function Stat({ label, value }: { label: string; value: string }) {
|
|
109
|
+
return (
|
|
110
|
+
<div className="flex flex-col">
|
|
111
|
+
<dt className="text-xs uppercase tracking-wide opacity-60">{label}</dt>
|
|
112
|
+
<dd className="text-sm">{value}</dd>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A list that renders nothing rather than an empty box — see `risks` in the schema. */
|
|
118
|
+
function Findings({ title, items }: { title: string; items: string[] }) {
|
|
119
|
+
if (items.length === 0) return null;
|
|
120
|
+
return (
|
|
121
|
+
<section className="flex flex-col gap-1">
|
|
122
|
+
<h3 className="text-sm font-medium opacity-70">{title}</h3>
|
|
123
|
+
<ul className="flex list-disc flex-col gap-1 pl-5">
|
|
124
|
+
{items.map((item) => (
|
|
125
|
+
<li key={item}>{item}</li>
|
|
126
|
+
))}
|
|
127
|
+
</ul>
|
|
128
|
+
</section>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function App() {
|
|
133
|
+
// The generic is what makes `run.status === "completed"` narrow to a TYPED
|
|
134
|
+
// `run.output` instead of `unknown`.
|
|
135
|
+
const { submit, run, pending, upload, pauseUpload, resumeUpload, error } =
|
|
136
|
+
useWorkflowSubmit<Audit>(WORKFLOW, { api });
|
|
137
|
+
const output = run?.status === "completed" ? run.output : undefined;
|
|
138
|
+
const audio = useAudioUrl(output?.audio);
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<main className="mx-auto flex max-w-3xl flex-col gap-6 p-8">
|
|
142
|
+
<header className="flex flex-col gap-1">
|
|
143
|
+
<h1 className="text-2xl font-medium">Call Audit</h1>
|
|
144
|
+
<p className="text-sm opacity-70">
|
|
145
|
+
Upload a call recording — any format. It comes back levelled, transcribed at its pauses,
|
|
146
|
+
and audited for risks and actions.
|
|
147
|
+
</p>
|
|
148
|
+
</header>
|
|
149
|
+
|
|
150
|
+
<Form onSubmit={submit} error={error} className="flex flex-col gap-4">
|
|
151
|
+
{/* Every control, from the workflow's own input schema. See the module doc. */}
|
|
152
|
+
<WorkflowFields workflow={WORKFLOW} />
|
|
153
|
+
<SubmitButton pending={pending} pendingLabel="Auditing…">
|
|
154
|
+
Audit the call
|
|
155
|
+
</SubmitButton>
|
|
156
|
+
</Form>
|
|
157
|
+
|
|
158
|
+
{/* The upload is its own wait, and the one nothing else can describe: the run
|
|
159
|
+
does not EXIST until the bytes are in, so there is no run id and nothing
|
|
160
|
+
for `<WorkflowProgress>` to read. */}
|
|
161
|
+
<UploadProgressBar upload={upload} onPause={pauseUpload} onResume={resumeUpload} />
|
|
162
|
+
|
|
163
|
+
{/* What the run itself says, from `report()` in the workflow's steps — which
|
|
164
|
+
for this template is the ffmpeg narration: what the file was, what it
|
|
165
|
+
measured, how many pauses were found. */}
|
|
166
|
+
<WorkflowProgress runId={run?.runId} api={api} />
|
|
167
|
+
|
|
168
|
+
{run?.status === "failed" && <p className="text-red-600">That one failed: {run.error}</p>}
|
|
169
|
+
|
|
170
|
+
{output !== undefined && (
|
|
171
|
+
<article className="flex flex-col gap-6">
|
|
172
|
+
<div className="flex flex-col gap-1">
|
|
173
|
+
<h2 className="text-xl">{output.headline}</h2>
|
|
174
|
+
<p className="text-sm opacity-70">
|
|
175
|
+
{output.source} · {duration(output.durationMs)} · {output.words} words
|
|
176
|
+
</p>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{/* What the pipeline did, which is this template's subject. Rendered rather
|
|
180
|
+
than logged because `blindCuts` is the one number that explains a bad
|
|
181
|
+
seam, and a reader has no other way to know. */}
|
|
182
|
+
<dl className="grid grid-cols-2 gap-3 rounded border border-current/10 p-4 sm:grid-cols-3">
|
|
183
|
+
<Stat label="Source codec" value={output.codec} />
|
|
184
|
+
<Stat label="Loudness in" value={`${output.loudnessBefore} LUFS`} />
|
|
185
|
+
<Stat label="Speech" value={`${output.speechPercent}%`} />
|
|
186
|
+
<Stat label="Segments" value={String(output.segments)} />
|
|
187
|
+
<Stat
|
|
188
|
+
label="Cut in speech"
|
|
189
|
+
value={output.blindCuts === 0 ? "none" : String(output.blindCuts)}
|
|
190
|
+
/>
|
|
191
|
+
<Stat label="Run time" value={duration(output.elapsedMs)} />
|
|
192
|
+
</dl>
|
|
193
|
+
|
|
194
|
+
<Findings title="Risks" items={output.risks} />
|
|
195
|
+
<Findings title="Actions" items={output.actions} />
|
|
196
|
+
|
|
197
|
+
<section className="flex flex-col gap-2">
|
|
198
|
+
<h3 className="text-sm font-medium opacity-70">
|
|
199
|
+
Read aloud · {duration(output.audioDurationMs)} ·{" "}
|
|
200
|
+
{Math.round(output.audioBytes / 1024)} KB
|
|
201
|
+
</h3>
|
|
202
|
+
{audio.error !== undefined && (
|
|
203
|
+
<p className="text-red-600">Could not load the audio: {audio.error}</p>
|
|
204
|
+
)}
|
|
205
|
+
{audio.url !== undefined && (
|
|
206
|
+
<>
|
|
207
|
+
{/* No `<track>`, and that is a judgement rather than an
|
|
208
|
+
oversight: the spoken text is rendered in full immediately
|
|
209
|
+
below this player, which is the same information a caption
|
|
210
|
+
track would carry. `spoken-summary` serves a one-cue WebVTT
|
|
211
|
+
data URL instead — worth reading for how, if a real track is
|
|
212
|
+
what a page needs. */}
|
|
213
|
+
<audio controls src={audio.url} className="w-full" />
|
|
214
|
+
{/* `download` works on an object URL because the bytes are already in
|
|
215
|
+
the tab; it is the href that could not carry the agent's bearer,
|
|
216
|
+
not the attribute. */}
|
|
217
|
+
<a href={audio.url} download="audit.mp3" className="text-sm underline">
|
|
218
|
+
Download audit.mp3
|
|
219
|
+
</a>
|
|
220
|
+
</>
|
|
221
|
+
)}
|
|
222
|
+
<p className="text-sm opacity-70">{output.spoken}</p>
|
|
223
|
+
</section>
|
|
224
|
+
|
|
225
|
+
<details className="text-sm">
|
|
226
|
+
<summary className="cursor-pointer opacity-70">Transcript</summary>
|
|
227
|
+
<p className="mt-2 whitespace-pre-wrap">{output.transcript}</p>
|
|
228
|
+
</details>
|
|
229
|
+
</article>
|
|
230
|
+
)}
|
|
231
|
+
</main>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
page({ name: "Call Audit", component: App });
|