@alexkroman1/aai-ui 0.16.0 → 0.18.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/README.md +6 -7
- package/dist/_submission-timing.d.ts +40 -0
- package/dist/_submission.d.ts +56 -0
- package/dist/_theme.d.ts +17 -0
- package/dist/_theme.test-d.d.ts +14 -0
- package/dist/{_utils-Mi-SCFA1.js → _utils-BlZBJPdj.js} +4 -5
- package/dist/_utils.d.ts +4 -5
- package/dist/agent-state-labels.d.ts +8 -3
- package/dist/agent-state.d.ts +4 -2
- package/dist/append-dictation.d.ts +23 -0
- package/dist/audio/dictation.d.ts +36 -0
- package/dist/audio/index.d.ts +4 -2
- package/dist/audio/index.js +44 -1
- package/dist/{chat-view-DIwPoAEG.js → chat-view-BwKpF0MN.js} +65 -45
- package/dist/{client-config-C7fQrioM.js → client-config-DcMCWmkO.js} +8 -13
- package/dist/client-config.d.ts +29 -10
- package/dist/components/_form-readiness.d.ts +2 -1
- package/dist/components/audio-result.d.ts +2 -0
- package/dist/components/bullet-list.d.ts +1 -0
- package/dist/components/button.d.ts +2 -2
- package/dist/components/button.js +1 -1
- package/dist/components/chat-view.js +1 -2
- package/dist/components/console-shell.d.ts +8 -5
- package/dist/components/controls.d.ts +1 -0
- package/dist/components/controls.js +2 -2
- package/dist/components/conversation-view.d.ts +1 -0
- package/dist/components/facts.d.ts +1 -0
- package/dist/components/form-fields.d.ts +31 -12
- package/dist/components/form-types.d.ts +48 -2
- package/dist/components/form.d.ts +10 -5
- package/dist/components/markdown.d.ts +1 -0
- package/dist/components/message-list.d.ts +1 -0
- package/dist/components/message-list.js +1 -2
- package/dist/components/session-controls.d.ts +7 -2
- package/dist/components/session-error-banner.d.ts +1 -0
- package/dist/components/session-state-dot.d.ts +45 -20
- package/dist/components/sidebar-layout.d.ts +2 -2
- package/dist/components/sidebar-layout.js +1 -1
- package/dist/components/start-screen.js +2 -2
- package/dist/components/tool-call-block.d.ts +3 -3
- package/dist/components/tool-call-block.js +1 -2
- package/dist/components/tool-call-row.d.ts +1 -0
- package/dist/components/tool-config-context.d.ts +23 -5
- package/dist/components/url-chips.d.ts +4 -3
- package/dist/components/workflow-fields.d.ts +36 -11
- package/dist/components/workflow-pending-note.d.ts +31 -12
- package/dist/components/workflow-run-error.d.ts +1 -0
- package/dist/components/workflow-run-panel.d.ts +1 -0
- package/dist/context-BF0KsI9X.js +473 -0
- package/dist/context.d.ts +8 -7
- package/dist/context.js +1 -381
- package/dist/default-client/assets/audio-CtBvSS3L.js +1 -0
- package/dist/default-client/assets/{capture-processor-CXCWxCO8.js → capture-processor-BZXisGjZ.js} +1 -1
- package/dist/default-client/assets/index-BkjCv4MX.css +2 -0
- package/dist/default-client/assets/index-DUQoaxdq.js +127 -0
- package/dist/default-client/assets/{playback-processor-DXHfyQD9.js → playback-processor-BHk-iXEU.js} +1 -1
- package/dist/default-client/index.html +2 -2
- package/dist/default-client.d.ts +1 -3
- package/dist/define-client.d.ts +32 -28
- package/dist/define-client.js +85 -44
- package/dist/dictation-transcriber.d.ts +49 -0
- package/dist/{eyebrow-UfmSz9yy.js → eyebrow-Bp4y1rZ9.js} +1 -1
- package/dist/{hooks-DN4gHYsj.js → hooks-1CoQY_Fc.js} +2 -2
- package/dist/hooks.js +1 -2
- package/dist/inbox-protocol.d.ts +14 -4
- package/dist/inbox.d.ts +1 -0
- package/dist/index.d.ts +12 -9
- package/dist/index.js +905 -277
- package/dist/internal.d.ts +1 -1
- package/dist/internal.js +6 -6
- package/dist/linked-client.d.ts +2 -0
- package/dist/{message-list-DHZcLhRA.js → message-list-BBHLK05X.js} +24 -13
- package/dist/page.d.ts +2 -0
- package/dist/phone.d.ts +1 -0
- package/dist/route-fetch.d.ts +8 -1
- package/dist/session/audio-setup.d.ts +1 -1
- package/dist/session/audio-state.d.ts +6 -6
- package/dist/session/index.js +1 -1
- package/dist/session/messages.d.ts +1 -9
- package/dist/session/preconnect.d.ts +1 -1
- package/dist/session/types.d.ts +4 -0
- package/dist/{session-CYdjRvbl.js → session-Rud8mmU-.js} +26 -15
- package/dist/stored-value.d.ts +2 -0
- package/dist/{tool-call-block-uXtMOUWN.js → tool-call-block-C8s6AhjS.js} +4 -4
- package/dist/tool-config-context-Bd-7xrfP.js +28 -0
- package/dist/types.d.ts +95 -6
- package/dist/{url-chips-oyy_Eiqh.js → url-chips-C_SHU6fq.js} +2 -2
- package/dist/use-client-runs.d.ts +2 -0
- package/dist/use-conversation-log.d.ts +2 -0
- package/dist/use-conversation.d.ts +24 -9
- package/dist/use-copy.d.ts +1 -0
- package/dist/use-dictation.d.ts +145 -0
- package/dist/use-download-url.d.ts +6 -1
- package/dist/use-flash.d.ts +1 -0
- package/dist/use-inbox.d.ts +2 -0
- package/dist/use-push-to-talk.d.ts +6 -1
- package/dist/use-route-mutation.d.ts +3 -0
- package/dist/use-route.d.ts +51 -7
- package/dist/use-run-key.d.ts +6 -1
- package/dist/use-session-controls.d.ts +1 -0
- package/dist/use-tap-to-talk.d.ts +5 -2
- package/dist/{use-user-transcript-uyHhzy4d.js → use-user-transcript-lOu_uMnf.js} +1 -1
- package/dist/use-user-transcript.d.ts +5 -1
- package/dist/use-workflow-form.d.ts +76 -167
- package/dist/use-workflow-progress.d.ts +5 -0
- package/dist/{use-workflow-run-DdhWmbx-.js → use-workflow-run-BTEemod0.js} +2 -2
- package/dist/use-workflow-run.d.ts +5 -0
- package/dist/use-workflow-runs.d.ts +18 -4
- package/dist/use-workflow-stream.d.ts +33 -7
- package/dist/use-workflows.d.ts +10 -2
- package/dist/workflow-client.d.ts +5 -0
- package/dist/workflow-def-types.d.ts +18 -2
- package/dist/workflow-field-kind.d.ts +66 -10
- package/dist/workflow-submission.d.ts +265 -0
- package/dist/worklets/capture-processor.d.ts +3 -3
- package/dist/worklets/capture-processor.js +2 -2
- package/dist/worklets/playback-processor.d.ts +3 -3
- package/dist/worklets/playback-processor.js +2 -2
- package/package.json +2 -2
- package/dist/default-client/assets/audio-DzwGxdv4.js +0 -1
- package/dist/default-client/assets/index-BOTLwsNO.js +0 -126
- package/dist/default-client/assets/index-_-x5JLLa.css +0 -2
- package/dist/tool-config-context-Es4YUzV2.js +0 -19
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a workflow SUBMISSION is, as a type: the object `useWorkflowSubmit` and
|
|
3
|
+
* `useWorkflowStream` both hand back. Moved out of `use-workflow-form.ts` at
|
|
4
|
+
* the source-length cap, on the seam `form-types.ts` is cut on — the two hooks
|
|
5
|
+
* and the form statechart all name these, and a type living in one of them
|
|
6
|
+
* would make the others import a hook to say what they return.
|
|
7
|
+
*/
|
|
8
|
+
import type { UploadProgress, WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
|
|
9
|
+
import type { FormValues } from "./components/form-types.ts";
|
|
10
|
+
import type { WorkflowRun } from "./workflow-client.ts";
|
|
11
|
+
import type { SubmitInputOf } from "./workflow-def-types.ts";
|
|
12
|
+
/**
|
|
13
|
+
* What {@link WorkflowSubmission.upload} reports while the bytes are going.
|
|
14
|
+
*
|
|
15
|
+
* The SDK's per-request {@link UploadProgress} plus WHICH file it describes,
|
|
16
|
+
* because a form is allowed more than one and a bar over "the upload" would
|
|
17
|
+
* restart at zero partway through with nothing to say why.
|
|
18
|
+
*
|
|
19
|
+
* @sealed
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
export type UploadStatus = UploadProgress & {
|
|
23
|
+
/** The file being sent, by the name the picker gave it. */
|
|
24
|
+
name: string;
|
|
25
|
+
/** Which file of the submission this is, counting from 1. */
|
|
26
|
+
index: number;
|
|
27
|
+
/** How many files this submission sends in total. */
|
|
28
|
+
count: number;
|
|
29
|
+
/**
|
|
30
|
+
* Whether the person has parked this upload.
|
|
31
|
+
*
|
|
32
|
+
* A paused upload is not a stopped one: the windows already stored stay stored,
|
|
33
|
+
* `loaded` holds where it got to, and resuming sends what is missing rather than
|
|
34
|
+
* the file. So a bar rendering this reads "Paused at 62%", never "62% and
|
|
35
|
+
* frozen" — which is what a page could otherwise only guess from a number that
|
|
36
|
+
* stopped moving, the same ambiguity `complete` exists to remove on the run side.
|
|
37
|
+
*/
|
|
38
|
+
paused: boolean;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* The two marks a submission's wall clock is read from — press to settle,
|
|
42
|
+
* across the upload, the run, and the gap between them, which no server-side
|
|
43
|
+
* number can give: `output.elapsedMs` is the RUN's own clock and begins after
|
|
44
|
+
* the upload. Only the browser holds both ends.
|
|
45
|
+
*
|
|
46
|
+
* `performance.now()` marks rather than elapsed durations, and that is the
|
|
47
|
+
* load-bearing part: an elapsed number is stale the instant it is computed, so
|
|
48
|
+
* a page holding one has to re-render to keep it true. A mark lets the one
|
|
49
|
+
* section that prints a running clock re-render on its own interval and read
|
|
50
|
+
* `performance.now() - submittedAt` at render. Monotonic, so a clock
|
|
51
|
+
* correction cannot make a run look instant, or negative.
|
|
52
|
+
*
|
|
53
|
+
* @sealed
|
|
54
|
+
* @public
|
|
55
|
+
*/
|
|
56
|
+
export type WorkflowSubmissionTiming = {
|
|
57
|
+
/** When `submit()` was called on this mount, or `undefined` before the first. */
|
|
58
|
+
submittedAt?: number | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* When the submission settled — the run reached a terminal status, or the
|
|
61
|
+
* submit itself failed — measured at that moment rather than at the next
|
|
62
|
+
* render. `undefined` while it is in flight, and for a run this page did
|
|
63
|
+
* not start (there was no press to time from).
|
|
64
|
+
*/
|
|
65
|
+
settledAt?: number | undefined;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Who can find the run again — the lifetime of the key it was recorded under.
|
|
69
|
+
*
|
|
70
|
+
* - `"tab"` — `useWorkflowSubmit`'s own `sessionStorage` key: a reload or a
|
|
71
|
+
* same-tab navigation finds the run; a new tab does not.
|
|
72
|
+
* - `"browser"` — a `localStorage` key (`key: { storage: "local" }`, or
|
|
73
|
+
* `useRunKey({ storage: "local" })`), or a key the page supplied itself (an
|
|
74
|
+
* account's id): any tab on this browser finds it, so closing the tab loses
|
|
75
|
+
* nothing.
|
|
76
|
+
* - `"none"` — nothing will find it: `recover: false`, or `useWorkflowStream`,
|
|
77
|
+
* whose run is reading the bytes THIS page is sending and is ended, not
|
|
78
|
+
* orphaned, by a reload.
|
|
79
|
+
*
|
|
80
|
+
* `<WorkflowPendingNote>` reads it to say the right sentence; the raw fact is
|
|
81
|
+
* published so a page with its own note can too.
|
|
82
|
+
*
|
|
83
|
+
* @public
|
|
84
|
+
*/
|
|
85
|
+
export type WorkflowSubmissionScope = "tab" | "browser" | "none";
|
|
86
|
+
/**
|
|
87
|
+
* What {@link useWorkflowSubmit} returns.
|
|
88
|
+
*
|
|
89
|
+
* @see {@link WorkflowStreamSubmission} — an ALIAS of this type, returned by
|
|
90
|
+
* `useWorkflowStream`, which is a drop-in sibling. Exactly two fields MEAN
|
|
91
|
+
* something different there, and both differences follow from WHEN the run is
|
|
92
|
+
* created: there, `submit()` resolves when the UPLOAD finishes rather than when
|
|
93
|
+
* the run is accepted, and `run` is non-`undefined` from before the bytes are
|
|
94
|
+
* in, so a page can render `<WorkflowProgress>` beside the upload bar instead
|
|
95
|
+
* of after it. Here the run does not exist until the last byte lands.
|
|
96
|
+
*
|
|
97
|
+
* @sealed
|
|
98
|
+
*/
|
|
99
|
+
export type WorkflowSubmission<R = unknown, I = unknown> = {
|
|
100
|
+
/**
|
|
101
|
+
* Start a run with this input. Resolves once the run EXISTS — progress
|
|
102
|
+
* arrives through `run` — so a `<Form>`'s handler can await it to know the
|
|
103
|
+
* submission was accepted.
|
|
104
|
+
*/
|
|
105
|
+
submit: (input: I) => Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Start a run from a `<Form>`'s values, which are UNVALIDATED.
|
|
108
|
+
*
|
|
109
|
+
* The same function as {@link WorkflowSubmission.submit}, with the type the
|
|
110
|
+
* form path can honestly offer. `FormValues` is `Record<string, unknown>`
|
|
111
|
+
* scraped off the DOM at submit time — a `<TextField name="limit">`
|
|
112
|
+
* contributes a string whatever the schema says — so the shape is not known
|
|
113
|
+
* here and the SERVER is what checks it against the workflow's schema.
|
|
114
|
+
*
|
|
115
|
+
* Two doors rather than one loose one: `submit` takes the workflow's own
|
|
116
|
+
* input type, so a hand-built object is checked at compile time and
|
|
117
|
+
* `submit({ ur1: 42 })` is an error; widening it to accept `FormValues` would
|
|
118
|
+
* have made every object satisfy it and given the typing back. Reaching for
|
|
119
|
+
* this one is the author saying "these came from a form", which is a fact
|
|
120
|
+
* about the values and not a cast.
|
|
121
|
+
*/
|
|
122
|
+
submitForm: (values: FormValues) => Promise<void>;
|
|
123
|
+
/** Clear the run and any error, putting the form back to its initial state. */
|
|
124
|
+
reset: () => void;
|
|
125
|
+
/**
|
|
126
|
+
* End the current run's `sleep()` early — "file it now" — resolving how many
|
|
127
|
+
* pending sleeps were interrupted.
|
|
128
|
+
*
|
|
129
|
+
* Bound to the run this submission is following, which is the point: it is
|
|
130
|
+
* the only reason a page holding one of these hooks needed an `api` of its
|
|
131
|
+
* own. `0` is an answer rather than a failure (the run had already moved past
|
|
132
|
+
* its wait, or there is no run yet), so nothing here has to be guarded.
|
|
133
|
+
*/
|
|
134
|
+
wake: () => Promise<number>;
|
|
135
|
+
/**
|
|
136
|
+
* Stop the current run, resolving whether this call is what ended it.
|
|
137
|
+
*
|
|
138
|
+
* `false` for a run that had already finished, and for no run at all — the
|
|
139
|
+
* SDK's contract, because two tabs pressing Stop is ordinary. Distinct from
|
|
140
|
+
* `reset()`, which puts the FORM back and leaves the run running.
|
|
141
|
+
*/
|
|
142
|
+
cancel: () => Promise<boolean>;
|
|
143
|
+
/** The run, once started, followed to completion. */
|
|
144
|
+
run: WorkflowRun<R> | undefined;
|
|
145
|
+
/**
|
|
146
|
+
* True from `submit()` on this mount until `reset()` — did THIS page start
|
|
147
|
+
* the run it is showing?
|
|
148
|
+
*
|
|
149
|
+
* A page needs it to say the right sentence and cannot derive it: a run
|
|
150
|
+
* ADOPTED by the mount-time lookup after a reload looks exactly like one this
|
|
151
|
+
* page started. Six templates kept a `useState(false)` next to this hook, set
|
|
152
|
+
* it in their `onSubmit` and mirrored it in their `onClear` — shadow state
|
|
153
|
+
* for a fact only this hook can know, since it is the thing that decides
|
|
154
|
+
* between `submit()` and the recovery lookup. One of the six grew a fourth
|
|
155
|
+
* branch and had to move the whole note into its own module with its own
|
|
156
|
+
* spec, which is what a seam missing one layer down looks like.
|
|
157
|
+
*
|
|
158
|
+
* The RAW fact rather than a derived "recovered", deliberately: with `run`
|
|
159
|
+
* these are three states, not two, and the third is the one a page most needs
|
|
160
|
+
* to explain. `startedHere` is "you pressed the button"; `!startedHere &&
|
|
161
|
+
* !run` is the mount-time lookup still going; `!startedHere && run` is a run
|
|
162
|
+
* this browser started earlier, now in front of somebody who did not press
|
|
163
|
+
* anything. A boolean meaning only the last of those cannot express the
|
|
164
|
+
* middle one.
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ```ts
|
|
168
|
+
* declare const submission: import("@alexkroman1/aai-ui").WorkflowSubmission;
|
|
169
|
+
* const note = submission.startedHere
|
|
170
|
+
* ? "You can close this tab or reload it — this page will find the run again."
|
|
171
|
+
* : submission.run === undefined
|
|
172
|
+
* ? "Looking for a run this tab started earlier…"
|
|
173
|
+
* : "Still working on the run this tab started earlier. Reloading is safe.";
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
startedHere: boolean;
|
|
177
|
+
/**
|
|
178
|
+
* True from `submit()` until the run reaches a terminal status.
|
|
179
|
+
*
|
|
180
|
+
* The WORK, not the request: a run outlives its `POST`, and a submit button
|
|
181
|
+
* that re-enabled on the response would invite a second submission of work
|
|
182
|
+
* already in flight.
|
|
183
|
+
*/
|
|
184
|
+
pending: boolean;
|
|
185
|
+
/**
|
|
186
|
+
* How far the submission's files have got, while any are still going.
|
|
187
|
+
*
|
|
188
|
+
* Undefined before the first byte and again from the moment the last one
|
|
189
|
+
* lands, so a page can render `{upload && <UploadProgressBar upload={upload} />}`
|
|
190
|
+
* and the bar exists exactly for as long as there is an upload to describe. A
|
|
191
|
+
* form with no files never sets it at all.
|
|
192
|
+
*
|
|
193
|
+
* The wait it covers is the one `run` cannot: a run does not EXIST until its
|
|
194
|
+
* input is stored, so `pending` is true and there is nothing to poll — which
|
|
195
|
+
* for a 200 MB recording is minutes of a page that looks stuck.
|
|
196
|
+
*/
|
|
197
|
+
upload: UploadStatus | undefined;
|
|
198
|
+
/**
|
|
199
|
+
* Park the upload where it is, stopping the bytes in flight.
|
|
200
|
+
*
|
|
201
|
+
* The windows already stored stay stored, so `resumeUpload()` sends what is
|
|
202
|
+
* missing rather than the file — which is the difference between a pause a
|
|
203
|
+
* person will actually use on a 200 MB recording and a cancel dressed up as one.
|
|
204
|
+
*
|
|
205
|
+
* `submit()`'s promise stays unresolved across a pause, because the submission
|
|
206
|
+
* genuinely has not finished: the run does not exist until the last byte lands,
|
|
207
|
+
* so resolving here would tell a `<Form>` the work was accepted when nothing has
|
|
208
|
+
* been started. A no-op when there is no upload in flight.
|
|
209
|
+
*/
|
|
210
|
+
pauseUpload: () => void;
|
|
211
|
+
/** Continue a paused upload, sending only the windows the store does not have. */
|
|
212
|
+
resumeUpload: () => void;
|
|
213
|
+
/** The submit's own failure (a rejected input), or the watch's. */
|
|
214
|
+
error: string | undefined;
|
|
215
|
+
/**
|
|
216
|
+
* Press-to-settle marks for the submission THIS mount made — see
|
|
217
|
+
* {@link WorkflowSubmissionTiming}. Both `undefined` before the first
|
|
218
|
+
* `submit()` and again after `reset()`; a run adopted by the mount-time
|
|
219
|
+
* lookup has no press to time from and sets neither.
|
|
220
|
+
*/
|
|
221
|
+
timing: WorkflowSubmissionTiming;
|
|
222
|
+
/**
|
|
223
|
+
* Who can find the run again after this page is gone — see
|
|
224
|
+
* {@link WorkflowSubmissionScope}. Derived from the hook's options (`key`,
|
|
225
|
+
* `recover`), so a page never restates it; `<WorkflowPendingNote>` reads it.
|
|
226
|
+
*/
|
|
227
|
+
scope: WorkflowSubmissionScope;
|
|
228
|
+
};
|
|
229
|
+
/**
|
|
230
|
+
* The submission `useWorkflowSubmit<D>` / `useWorkflowStream<D>` hands back for
|
|
231
|
+
* the workflow def `D`, named so a component can take one as a prop.
|
|
232
|
+
*
|
|
233
|
+
* The hook's own return is inferred, so a page that renders everything in one
|
|
234
|
+
* function never needs this. A page that splits does, and there was no way to
|
|
235
|
+
* write it from the outside without three names — `WorkflowSubmission`,
|
|
236
|
+
* `WorkflowOutputOf` and `SubmitInputOf` — composed by hand, which is what a
|
|
237
|
+
* `ReturnType<typeof useWorkflowSubmit>` cannot say because the hook is
|
|
238
|
+
* generic. `podcast-digest-workflow`'s `client.tsx` composed exactly that.
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* ```tsx
|
|
242
|
+
* // file: agent.ts
|
|
243
|
+
* import { agent, workflow } from "@alexkroman1/aai";
|
|
244
|
+
* import { z } from "zod";
|
|
245
|
+
*
|
|
246
|
+
* export const dailyDigest = workflow({
|
|
247
|
+
* input: z.object({ hour: z.number() }),
|
|
248
|
+
* output: z.object({ posted: z.string() }),
|
|
249
|
+
* run: async ({ hour }) => ({ posted: `daily at ${hour}:00` }),
|
|
250
|
+
* });
|
|
251
|
+
*
|
|
252
|
+
* export default agent({ name: "Digest", workflows: { dailyDigest } });
|
|
253
|
+
*
|
|
254
|
+
* // file: client.tsx
|
|
255
|
+
* import type { WorkflowSubmissionOf } from "@alexkroman1/aai-ui";
|
|
256
|
+
* import type { dailyDigest } from "./agent.ts";
|
|
257
|
+
*
|
|
258
|
+
* function ScheduleControls({ submission }: { submission: WorkflowSubmissionOf<typeof dailyDigest> }) {
|
|
259
|
+
* return submission.run?.status === "completed" ? <p>{submission.run.output.posted}</p> : null;
|
|
260
|
+
* }
|
|
261
|
+
* ```
|
|
262
|
+
*
|
|
263
|
+
* @public
|
|
264
|
+
*/
|
|
265
|
+
export type WorkflowSubmissionOf<D> = WorkflowSubmission<WorkflowOutputOf<D>, SubmitInputOf<D>>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** Raw worklet source — exported so tests can evaluate the processor directly. */
|
|
2
|
-
export declare const captureProcessorSource
|
|
3
|
-
declare const
|
|
4
|
-
export default
|
|
2
|
+
export declare const captureProcessorSource: string;
|
|
3
|
+
declare const captureProcessorUrl: string;
|
|
4
|
+
export default captureProcessorUrl;
|
|
@@ -92,6 +92,6 @@ class CaptureProcessor extends AudioWorkletProcessor {
|
|
|
92
92
|
|
|
93
93
|
registerProcessor('capture-processor', CaptureProcessor);
|
|
94
94
|
`;
|
|
95
|
-
|
|
95
|
+
const captureProcessorUrl = workletModuleUrl(captureProcessorSource);
|
|
96
96
|
//#endregion
|
|
97
|
-
export { captureProcessorSource,
|
|
97
|
+
export { captureProcessorSource, captureProcessorUrl as default };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** Raw worklet source — exported so tests can evaluate the processor directly. */
|
|
2
|
-
export declare const playbackProcessorSource = "\nclass PlaybackProcessor extends AudioWorkletProcessor {\n constructor(options) {\n super();\n const opts = options.processorOptions || {};\n // The node's context IS the playback context (audio/voice-io.ts asserts its rate),\n // so the worklet-global sampleRate is authoritative; the option exists\n // for the node-less test harness.\n const rate = opts.sampleRate ?? sampleRate;\n // Kept, because every derived quantity below reads it and `bufferedMs` in\n // process() is the one that used to read the worklet global instead. Those\n // agree in production (the option is unset, so `rate` IS `sampleRate`) and\n // diverge only in the node-less harness \u2014 which is the one place a spec\n // could ever assert on the reported backlog, so the divergence made the\n // number untestable rather than wrong.\n this.rate = rate;\n // The one fill target \u2014 for the start of a turn and for recovery after an\n // underrun alike. If 'done' arrives first (short utterance), start\n // immediately instead of waiting for audio that is never coming.\n this.fillSamples = Math.floor((rate * (opts.fillMs ?? 200)) / 1000);\n // Concealment source: a ring of the most recently played samples, looped\n // under a decaying gain to cover a gap. Sized to the fade window, with a\n // per-sample decay that reaches the floor exactly at its end.\n this.concealCapacity = Math.max(1, Math.floor((rate * 40) / 1000));\n this.concealBuf = new Float32Array(this.concealCapacity);\n this.concealDecay = Math.exp(Math.log(0.001) / this.concealCapacity);\n // Float32 ring buffer \u2014 PLAYBACK_BUFFER_SECONDS at the context sample\n // rate. Allocated once for the node's lifetime; per-turn state resets via\n // resetTurn(). writePos and readPos are absolute (monotonic) sample\n // counts; the buffer is indexed modulo capacity so a longer reply keeps\n // playing instead of writing past the end and going silent.\n this.capacity = rate * 60;\n this.samples = new Float32Array(this.capacity);\n // Cadence for the 'progress' report in process(), counted in samples so\n // the hot path never reads a clock. Survives resetTurn(): the host clamps\n // upward only, so a stale count costs at most one extra report.\n this.reportIntervalSamples = Math.floor((rate * 500) / 1000);\n this.sinceReportSamples = 0;\n // Platform endianness probe: the wire format is PCM16 little-endian, so\n // the Int16Array fast path in ingestBytes is only valid on LE hosts\n // (every shipping browser target; the DataView path is the fallback).\n this.littleEndian = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1;\n this.resetTurn();\n\n this.port.onmessage = (e) => {\n const d = e.data;\n if (d.event === 'write') {\n this.ingestBytes(d.buffer);\n } else if (d.event === 'interrupt') {\n // Applied eagerly, not deferred to the next process(): onmessage and\n // process() never interleave (one audio thread), and a deferred\n // interrupt would let 'write'/'done' frames for the NEXT turn\n // coalesce in ahead of it and be wiped by the reset along with the\n // cancelled turn's audio.\n this.stopTurn('interrupt');\n } else if (d.event === 'done') {\n this.isDone = true;\n // Echoed back on this turn's 'stop' so the host can tell WHICH turn\n // drained: a stop already in flight when a barge-in lands would\n // otherwise settle the next turn's done() the moment it arrives.\n this.doneTurn = d.turn ?? null;\n }\n };\n }\n\n // Reset per-turn state so the node is reusable across replies without\n // reallocating the sample buffer or re-instantiating the worklet.\n resetTurn() {\n this.isDone = false;\n // Host turn id carried by this turn's 'done' (null until one arrives).\n this.doneTurn = null;\n this.playing = false;\n // Whether any real audio has been rendered this turn. Separates a turn's\n // pre-roll (nothing to extrapolate from, and not a defect) from a\n // mid-turn underrun.\n this.hasPlayed = false;\n // Carry-over byte for split samples across chunks\n this.carry = null;\n this.writePos = 0;\n this.readPos = 0;\n // Concealment ring state and the current fade position.\n this.concealLen = 0;\n this.concealWrite = 0;\n this.concealPos = 0;\n this.concealGain = 1;\n // Episode flags, so a multi-quantum gap counts as one event.\n this.concealing = false;\n this.concealedSilence = false;\n // Reported to the host on 'stop'. A fresh object per turn: the one just\n // posted must not be mutated by the next turn.\n this.stats = {\n concealedSamples: 0,\n silentConcealedSamples: 0,\n concealmentEvents: 0,\n silentConcealmentEvents: 0,\n };\n }\n\n // End the current turn: notify the host and rearm for the next reply.\n // Must NOT return false from process() \u2014 a processor that stops is dead\n // for good, forcing a new node (and buffer) per reply.\n // `reason` ('interrupt' | 'done') tells the host which turn boundary this\n // stop belongs to \u2014 interrupt-stops are dropped host-side, flush() having\n // already settled that turn \u2014 and `turn` names WHICH turn drained (the id\n // this turn's 'done' carried), so a drain-stop still in flight when a\n // barge-in lands cannot settle the next turn's done() early.\n stopTurn(reason) {\n this.port.postMessage({ event: 'stop', reason, turn: this.doneTurn, stats: this.stats });\n this.resetTurn();\n }\n\n // Cover a quantum (from `start`) where real audio should have been.\n //\n // Before the turn's first samples there is nothing to extrapolate from, so\n // the gap is plain silence and counted as nothing \u2014 WebRTC likewise only\n // counts concealment once playout has begun. After that, loop the retained\n // tail under a decaying gain: a hard zero-fill is a discontinuity mid-word,\n // which is the click that makes a brief stall sound like breakage.\n coverGap(out, start) {\n if (!this.hasPlayed) {\n out.fill(0, start);\n return;\n }\n if (!this.concealing) {\n this.concealing = true;\n this.concealedSilence = false;\n this.stats.concealmentEvents++;\n }\n const total = out.length - start;\n const len = this.concealLen;\n let silent = 0;\n // Second condition: once the fade has decayed to the floor, every sample\n // the loop would emit is 0 anyway \u2014 bulk-fill instead of running 128\n // branchy iterations per quantum for the whole tail of a long stall.\n if (len === 0 || this.concealGain < 0.001) {\n out.fill(0, start);\n silent = total;\n } else {\n let g = this.concealGain;\n for (let i = start; i < out.length; i++) {\n if (g < 0.001) {\n // The fade has run out: keep counting the gap, but stop looping a\n // fragment that is now inaudible anyway.\n out[i] = 0;\n silent++;\n continue;\n }\n out[i] = this.concealBuf[this.concealPos] * g;\n this.concealPos = this.concealPos + 1 === len ? 0 : this.concealPos + 1;\n g *= this.concealDecay;\n }\n this.concealGain = g;\n }\n this.stats.concealedSamples += total;\n if (silent > 0) {\n this.stats.silentConcealedSamples += silent;\n if (!this.concealedSilence) {\n this.concealedSilence = true;\n this.stats.silentConcealmentEvents++;\n }\n }\n }\n\n // Retain the tail of a rendered quantum as the next gap's concealment\n // source, and close any episode the real audio just ended.\n rememberTail(out, n) {\n const cap = this.concealCapacity;\n const take = Math.min(n, cap);\n // Bulk copies (this runs on every cleanly rendered quantum): the tail is\n // one contiguous source run, landing in at most two ring runs.\n const tail = out.subarray(n - take, n);\n const first = Math.min(take, cap - this.concealWrite);\n this.concealBuf.set(tail.subarray(0, first), this.concealWrite);\n if (take > first) this.concealBuf.set(tail.subarray(first), 0);\n this.concealWrite = (this.concealWrite + take) % cap;\n this.concealLen = Math.min(cap, this.concealLen + take);\n // Read the loop oldest-first; once the ring is full the write cursor is\n // the oldest retained sample.\n this.concealPos = this.concealLen === cap ? this.concealWrite : 0;\n this.concealing = false;\n this.concealGain = 1;\n }\n\n ingestBytes(uint8) {\n let bytes = uint8;\n\n if (this.carry !== null) {\n const merged = new Uint8Array(1 + bytes.length);\n merged[0] = this.carry;\n merged.set(bytes, 1);\n bytes = merged;\n this.carry = null;\n }\n\n if (bytes.length % 2 !== 0) {\n this.carry = bytes[bytes.length - 1];\n bytes = bytes.subarray(0, bytes.length - 1);\n }\n\n if (bytes.length === 0) return;\n const numSamples = bytes.length / 2;\n const cap = this.capacity;\n const samples = this.samples;\n if (this.littleEndian && (bytes.byteOffset & 1) === 0) {\n // Fast path: 2-byte-aligned LE bytes wrap directly as an Int16Array;\n // copy wrap-aware in at most two runs with no per-sample DataView call\n // or modulo. This runs on the realtime audio thread.\n const int16 = new Int16Array(bytes.buffer, bytes.byteOffset, numSamples);\n let src = 0;\n let dst = this.writePos % cap;\n while (src < numSamples) {\n const run = Math.min(numSamples - src, cap - dst);\n for (let j = 0; j < run; j++) {\n samples[dst + j] = int16[src + j] / 0x8000;\n }\n src += run;\n dst = 0;\n }\n } else {\n // Odd byte offset (or big-endian host): fall back to per-sample reads.\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.length);\n let dst = this.writePos % cap;\n for (let i = 0; i < numSamples; i++) {\n samples[dst] = view.getInt16(i * 2, true) / 0x8000;\n dst++;\n if (dst === cap) dst = 0;\n }\n }\n this.writePos += numSamples;\n // If the producer outran the consumer by more than the buffer holds, drop\n // the oldest unplayed audio rather than reading samples we've overwritten.\n if (this.writePos - this.readPos > this.capacity) {\n this.readPos = this.writePos - this.capacity;\n }\n }\n\n process(inputs, outputs) {\n // No output wired up yet \u2014 nothing to render this quantum. Throwing here\n // would permanently kill the processor (the node is persistent per\n // session), so guard like the capture processor does.\n if (!outputs[0] || !outputs[0][0]) return true;\n const out = outputs[0][0];\n const avail = this.writePos - this.readPos;\n\n // Report the unplayed backlog to the host (`playback_progress`). This IS\n // the closed-loop signal: the host otherwise models playback open-loop \u2014\n // every forwarded chunk assumed to start playing on arrival at exactly\n // 1.0x \u2014 and cannot see a buffer that has run ahead of the wall clock.\n // Measured against a client draining at 0.6x, the host declared the line\n // silent while it still held seconds of the reply, and then opened the\n // speaking-edge gate over speech the caller had not heard.\n //\n // Sent from process() rather than a timer because this is the only place\n // that sees the buffer on the audio thread; the counter is in QUANTA, so\n // it costs an integer compare per render and no clock read. Silence is not\n // reported: an empty buffer is what the host already assumes.\n this.sinceReportSamples += out.length;\n if (this.sinceReportSamples >= this.reportIntervalSamples) {\n this.sinceReportSamples = 0;\n if (avail > 0) {\n this.port.postMessage({ event: 'progress', bufferedMs: (avail / this.rate) * 1000 });\n }\n }\n\n // Filling: wait for the target. 'done' short-circuits it \u2014 what is\n // buffered is all there will be, so there is nothing left to wait for.\n if (!this.playing) {\n if (avail >= this.fillSamples || this.isDone) {\n this.playing = true;\n } else {\n this.coverGap(out, 0);\n return true;\n }\n }\n\n // Underrun: this quantum cannot be filled and more audio is still coming.\n // Go back to filling and cover the gap, leaving\n // readPos untouched \u2014 the fragment stays buffered and plays intact once\n // the buffer recovers, instead of being dribbled out a few samples at a\n // time for the rest of the turn.\n if (avail < out.length && !this.isDone) {\n this.playing = false;\n this.coverGap(out, 0);\n return true;\n }\n\n if (avail > 0) {\n const n = Math.min(avail, out.length);\n // Copy from the ring buffer, splitting across the wrap boundary.\n const start = this.readPos % this.capacity;\n const first = Math.min(n, this.capacity - start);\n out.set(this.samples.subarray(start, start + first), 0);\n if (n > first) out.set(this.samples.subarray(0, n - first), first);\n this.readPos += n;\n // Only reachable with n < out.length on the turn's final partial\n // quantum (the underrun branch above catches every other case).\n out.fill(0, n);\n this.hasPlayed = true;\n this.rememberTail(out, n);\n return true;\n }\n\n // Drained and done: end the turn. Not reachable mid-turn \u2014 an empty\n // buffer with audio still coming is the underrun branch above.\n out.fill(0);\n if (this.isDone) {\n this.stopTurn('done');\n }\n return true;\n }\n}\n\nregisterProcessor('playback-processor', PlaybackProcessor);\n";
|
|
3
|
-
declare const
|
|
4
|
-
export default
|
|
2
|
+
export declare const playbackProcessorSource: string;
|
|
3
|
+
declare const playbackProcessorUrl: string;
|
|
4
|
+
export default playbackProcessorUrl;
|
|
@@ -316,6 +316,6 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
|
|
316
316
|
|
|
317
317
|
registerProcessor('playback-processor', PlaybackProcessor);
|
|
318
318
|
`;
|
|
319
|
-
|
|
319
|
+
const playbackProcessorUrl = workletModuleUrl(playbackProcessorSource);
|
|
320
320
|
//#endregion
|
|
321
|
-
export {
|
|
321
|
+
export { playbackProcessorUrl as default, playbackProcessorSource };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alexkroman1/aai-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"use-stick-to-bottom": "^1.1.6",
|
|
47
47
|
"use-sync-external-store": "^1.7.0",
|
|
48
48
|
"xstate": "^5.33.2",
|
|
49
|
-
"@alexkroman1/aai": "0.
|
|
49
|
+
"@alexkroman1/aai": "0.18.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{a as e,i as t,t as n}from"./index-BOTLwsNO.js";var r={echoCancellation:!0,noiseSuppression:!1,autoGainControl:!1,voiceIsolation:!1};function i(e,t,n){if(e!==t)throw Error(`Browser refused the ${n} sample rate: asked for ${t} Hz, got ${e} Hz`)}function a(e){let t=Error(`Audio ${e} worklet crashed`);return console.error(`[aai-ui]`,t.message),t}function o(e){e.then(e=>{for(let t of e.getTracks())t.stop()}).catch(()=>{})}function s(e,t,n){let r=new AudioWorkletNode(e,`capture-processor`,{channelCount:1,channelCountMode:`explicit`}),i=null;return r.port.onmessage=e=>{let r=e.data;r.event===`chunk`&&r.buffer?t(r.buffer):r.event===`silent`?n?.():r.event===`stopped`&&(i?.(),i=null)},{node:r,start(){r.port.postMessage({event:`start`})},stop(){let{promise:e,resolve:t}=Promise.withResolvers(),n=setTimeout(t,250);return i=()=>{clearTimeout(n),t()},r.port.postMessage({event:`stop`}),e}}}async function c(e){let{sampleRate:t,captureWorkletSrc:n,maxSeconds:a}=e,c=new AudioContext({sampleRate:t,latencyHint:`interactive`}),l=navigator.mediaDevices.getUserMedia({audio:{deviceId:{ideal:`default`},...r}}),u;try{[u]=await Promise.all([l,c.resume(),c.audioWorklet.addModule(n)]),i(c.sampleRate,t,`capture`)}catch(e){throw o(l),await c.close().catch(()=>{}),e}let d=Math.round(t*a)*2,f=[],p=0,m=!1,h=null,g=c.createMediaStreamSource(u),_=s(c,e=>{if(h){h.onChunk(e);return}for(f.push(e),p+=e.byteLength;p>d&&f.length>1;)p-=f.shift()?.byteLength??0},()=>{h?h.onSilent?.():m=!0});g.connect(_.node),_.start();let v=()=>{let e=f;return f=[],p=0,e},y=!1,b=async()=>{y||(y=!0,g.disconnect(),_.node.disconnect(),await c.close().catch(e=>{console.warn(`AudioContext close failed:`,e)}))};return{sampleRate:t,stream:u,ctx:c,handOff(e){h=e;let t=v();return t.length>0&&e.onBuffered(t),m&&e.onSilent?.(),{mic:g,capture:_}},async detach(){return await _.stop(),await b(),{chunks:v(),sampleRate:t}},async close(){y||await _.stop();for(let e of u.getTracks())e.stop();await b()}}}function l(e){let t=new Int16Array(e.length);for(let n=0;n<e.length;n++){let r=Math.max(-1,Math.min(1,e[n]??0));t[n]=r<0?r*32768:r*32767}return t.buffer}async function u(e,t,r){let i=e.reduce((e,t)=>e+t.byteLength/2,0);if(i===0)return[];if(t===r)return e;try{let a=Math.round(i*r/t),o=new OfflineAudioContext(1,a,r),s=o.createBuffer(1,i,t),c=s.getChannelData(0),u=0;for(let t of e)for(let e of new Int16Array(t))c[u++]=e/32768;let d=o.createBufferSource();d.buffer=s,d.connect(o.destination),d.start();let f=(await o.startRendering()).getChannelData(0),p=Math.max(1,Math.round(r*n)),m=[];for(let e=0;e<f.length;e+=p)m.push(l(f.subarray(e,e+p)));return m}catch(e){return console.warn(`[aai-ui] pre-connect audio dropped: resample failed`,e),[]}}async function d(e,t,n){let r=e.createMediaStreamSource(t),i=s(e,n.onMicData,n.onMicSilent);if(r.connect(i.node),n.buffered){let{chunks:t,sampleRate:r}=n.buffered,i=await u(t,r,e.sampleRate);i.length>0&&n.onBuffered(i)}return i.start(),{mic:r,capture:i}}async function f(n){let{sttSampleRate:s,ttsSampleRate:c,captureWorkletSrc:l,playbackWorkletSrc:u,onMicData:f,onError:p,onPlaybackStats:m,onPlaybackProgress:h,onMicSilent:g,preConnect:_}=n,v=n.onPreConnectAudio??(e=>{for(let t of e)f(t)}),y=_?.sampleRate===s?_:null,b=_&&!y?await _.detach():null,x=new AudioContext({sampleRate:c,latencyHint:`playback`}),S=!y&&s===c,C=y?.ctx??(S?x:new AudioContext({sampleRate:s,latencyHint:`interactive`}));async function w(){let e=S?[x]:[x,C];await Promise.all(e.map(e=>e.close().catch(e=>{console.warn(`AudioContext close failed:`,e)})))}let T=_?Promise.resolve(_.stream):navigator.mediaDevices.getUserMedia({audio:{deviceId:{ideal:`default`},...r}}),E;try{[E]=await Promise.all([T,x.resume(),C.resume(),y?void 0:C.audioWorklet.addModule(l),x.audioWorklet.addModule(u)]),i(C.sampleRate,s,`capture`),i(x.sampleRate,c,`playback`)}catch(e){throw o(T),await w(),e}let{mic:D,capture:O}=y?y.handOff({onBuffered:v,onChunk:f,onSilent:g}):await d(C,E,{onMicData:f,onMicSilent:g,buffered:b,onBuffered:v});O.node.onprocessorerror=()=>p?.(a(`capture`));let k=null,A=0,j=null,M=new AbortController;function N(){let e=j;j=null,e?.settle()}function P(e){e.stats&&e.stats.concealedSamples>0&&m?.(e.stats),e.reason!==`interrupt`&&(j===null||e.turn===j.turn)&&N()}function F(){if(k)return k;let e=new AudioWorkletNode(x,`playback-processor`);return e.connect(x.destination),e.port.onmessage=e=>{e.data.event===`stop`?P(e.data):e.data.event===`progress`&&h?.(e.data.bufferedMs)},e.onprocessorerror=()=>{let e=a(`playback`);N(),p?.(e)},k=e,e}let I={enqueue(e){M.signal.aborted||e.byteLength!==0&&F().port.postMessage({event:`write`,buffer:new Uint8Array(e)},[e])},done(){if(!k)return Promise.resolve();let n=++A;if(k.port.postMessage({event:`done`,turn:n}),x.state!==`running`)return N(),Promise.resolve();let{promise:r,resolve:i}=Promise.withResolvers();N();let a=()=>{clearInterval(o),clearTimeout(s),j?.settle===a&&(j=null),i()},o=setInterval(()=>{x.state!==`running`&&a()},e),s=setTimeout(a,t);return j={turn:n,settle:a},r},flush(){k&&(N(),k.port.postMessage({event:`interrupt`}))},async close(){if(!M.signal.aborted){M.abort(),await O.stop(),D.disconnect(),O.node.disconnect(),k&&k.disconnect();for(let e of E.getTracks())e.stop();await w()}},async[Symbol.asyncDispose](){await I.close()}};return I}export{f as createVoiceIO,c as openPreConnectCapture};
|