@artooi/ag-ui-web-component 0.42.0 → 0.43.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/CHANGELOG.md +129 -1
- package/README.md +50 -18
- package/dist/ag-ui-web-component.bundle.js +45 -45
- package/dist/ag-ui-web-component.bundle.js.map +3 -3
- package/dist/core/ag_ui_chat.d.ts +36 -8
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/index.js +213 -73
- package/dist/index.js.map +3 -3
- package/dist/tools/tool_dispatch.d.ts +11 -1
- package/dist/tools/tool_dispatch.d.ts.map +1 -1
- package/dist/ui/composer/voice_input.d.ts +2 -2
- package/dist/ui/composer/voice_input.d.ts.map +1 -1
- package/dist/ui/history/conversation_history.d.ts +40 -24
- package/dist/ui/history/conversation_history.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +5 -0
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/core/ag_ui_chat.ts +125 -35
- package/src/tools/tool_dispatch.ts +40 -4
- package/src/ui/composer/voice_input.ts +57 -23
- package/src/ui/history/conversation_history.ts +103 -34
- package/src/ui/ui_strings.ts +6 -0
- package/src/version.ts +1 -1
|
@@ -58,6 +58,8 @@ export class VoiceInput {
|
|
|
58
58
|
#capTimer: ReturnType<typeof setTimeout> | null = null;
|
|
59
59
|
#hitCap = false;
|
|
60
60
|
#disposed = false;
|
|
61
|
+
/** Identifies the newest start; an older one that settles is stale. */
|
|
62
|
+
#startToken: object = {};
|
|
61
63
|
|
|
62
64
|
constructor(options: VoiceInputOptions) {
|
|
63
65
|
this.#transcribe = options.transcribe;
|
|
@@ -93,35 +95,66 @@ export class VoiceInput {
|
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
async #start(): Promise<void> {
|
|
96
|
-
|
|
98
|
+
// The button reads idle for the whole of the permission prompt, so a second
|
|
99
|
+
// click during it is a second start. The latest click wins: this token is
|
|
100
|
+
// replaced by every start, and whichever one settles while it is not the
|
|
101
|
+
// newest is stale. It is a token and not an in-flight flag on purpose:
|
|
102
|
+
// getUserMedia may never settle (a dismissed prompt), and a flag waiting
|
|
103
|
+
// for it would leave the button dead until reload.
|
|
104
|
+
const token = {};
|
|
105
|
+
this.#startToken = token;
|
|
106
|
+
let stream: MediaStream | null;
|
|
97
107
|
try {
|
|
98
108
|
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
99
109
|
} catch {
|
|
100
|
-
|
|
101
|
-
return;
|
|
110
|
+
stream = null;
|
|
102
111
|
}
|
|
103
|
-
if (this.#disposed) {
|
|
104
|
-
//
|
|
105
|
-
// user-key changed, while it was open.
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
112
|
+
if (token !== this.#startToken || this.#disposed) {
|
|
113
|
+
// Superseded by a later click, or the prompt outlived the control: the
|
|
114
|
+
// element left, or user-key changed, while it was open. Whatever this
|
|
115
|
+
// call was granted is released and nothing shared is touched: #stream,
|
|
116
|
+
// #recorder and the cap timer belong to the newer start, and recording
|
|
117
|
+
// now on a disposed control would light the browser's indicator for a
|
|
118
|
+
// button nobody can see or stop (dispose has already run, so nothing
|
|
119
|
+
// else would ever release these tracks). A refusal is dropped the same
|
|
120
|
+
// way, so a click nobody is waiting on draws no error over the newer one.
|
|
121
|
+
for (const track of stream?.getTracks() ?? []) {
|
|
109
122
|
track.stop();
|
|
110
123
|
}
|
|
111
124
|
return;
|
|
112
125
|
}
|
|
126
|
+
if (stream === null) {
|
|
127
|
+
this.#fail(this.#strings.transcriptionFailed);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
113
130
|
this.#stream = stream;
|
|
114
131
|
this.#chunks = [];
|
|
115
132
|
this.#hitCap = false;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
133
|
+
let recorder: MediaRecorder;
|
|
134
|
+
try {
|
|
135
|
+
recorder = new MediaRecorder(stream);
|
|
136
|
+
recorder.addEventListener("dataavailable", (event) => {
|
|
137
|
+
this.#chunks.push(event.data);
|
|
138
|
+
});
|
|
139
|
+
recorder.addEventListener("stop", () => {
|
|
140
|
+
void this.#finish(recorder.mimeType);
|
|
141
|
+
});
|
|
142
|
+
this.#recorder = recorder;
|
|
143
|
+
recorder.start();
|
|
144
|
+
} catch {
|
|
145
|
+
// A granted stream the browser will not record: one whose tracks have
|
|
146
|
+
// already ended (Chromium throws NotSupportedError from start), a device
|
|
147
|
+
// that went away between the grant and the start, or no MediaRecorder at
|
|
148
|
+
// all. It is a refused mic by another route, so it ends the same way: the
|
|
149
|
+
// tracks are released (the stream is already stored, and nothing else
|
|
150
|
+
// would stop it), the button shows the failure, and nothing reaches the
|
|
151
|
+
// click handler, which discards the promise. A recorder whose start threw
|
|
152
|
+
// can still report "recording" and still holds its listeners, but #fail
|
|
153
|
+
// clears #recorder, so neither #stop nor dispose can reach it and its stop
|
|
154
|
+
// listener never runs #finish.
|
|
155
|
+
this.#fail(this.#strings.transcriptionFailed);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
125
158
|
// Nothing else ends a recording: `MediaRecorder` runs until it is told to
|
|
126
159
|
// stop, so without this the only exits are a second click and `dispose()`.
|
|
127
160
|
this.#capTimer = setTimeout(() => {
|
|
@@ -153,8 +186,8 @@ export class VoiceInput {
|
|
|
153
186
|
* a detached element.
|
|
154
187
|
*
|
|
155
188
|
* Disposal is final: nothing un-disposes an instance, and the host's next
|
|
156
|
-
* wiring builds a new one. So the disposed flag alone tells a
|
|
157
|
-
*
|
|
189
|
+
* wiring builds a new one. So the disposed flag alone tells a continuation
|
|
190
|
+
* that outlived the control from a live one, and a clip already posted when this runs is
|
|
158
191
|
* dropped whenever its answer arrives.
|
|
159
192
|
*/
|
|
160
193
|
dispose(): void {
|
|
@@ -213,9 +246,10 @@ export class VoiceInput {
|
|
|
213
246
|
}
|
|
214
247
|
|
|
215
248
|
#fail(message: string): void {
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
// control has nothing to release and no
|
|
249
|
+
// A transcription that fails can settle after dispose. (A refused prompt
|
|
250
|
+
// cannot reach here that way: #start drops a stale or disposed refusal
|
|
251
|
+
// before it calls this.) A disposed control has nothing to release and no
|
|
252
|
+
// tooltip to draw.
|
|
219
253
|
if (this.#disposed) {
|
|
220
254
|
return;
|
|
221
255
|
}
|
|
@@ -98,16 +98,21 @@ export interface ConversationHistoryHost {
|
|
|
98
98
|
readonly releaseClient: () => void;
|
|
99
99
|
/**
|
|
100
100
|
* Whether an interaction is in flight, which the composer owns: from the
|
|
101
|
-
* moment a send or a retry is taken, or a restore
|
|
102
|
-
*
|
|
101
|
+
* moment a send or a retry is taken, or a restore starts loading, not only
|
|
102
|
+
* once its run has started.
|
|
103
103
|
*/
|
|
104
104
|
readonly running: () => boolean;
|
|
105
105
|
/**
|
|
106
|
-
* Hold the composer as a send holds it from the moment it is taken:
|
|
107
|
-
*
|
|
108
|
-
*
|
|
106
|
+
* Hold the composer as a send holds it from the moment it is taken: the
|
|
107
|
+
* built-in Send parks its turn, a Retry and a pick refuse. Let go of when the
|
|
108
|
+
* run starts, by a Stop, or by {@link RunHold.release}.
|
|
109
|
+
*
|
|
110
|
+
* A host's send refuses too, unless `restored` is given: then the hold is a
|
|
111
|
+
* restore's with no run to resume, and a host's send waits for `restored` to
|
|
112
|
+
* settle and sends after it, with the conversation drawn. `restored` settles
|
|
113
|
+
* once that restore has ended, or has been stopped, and never rejects.
|
|
109
114
|
*/
|
|
110
|
-
readonly holdRun: () => RunHold;
|
|
115
|
+
readonly holdRun: (restored: Promise<void> | null) => RunHold;
|
|
111
116
|
/** Stop the in-flight run. */
|
|
112
117
|
readonly cancelRun: () => void;
|
|
113
118
|
/** Drop the in-memory run and transcript, leaving the thread untouched. */
|
|
@@ -120,8 +125,9 @@ export interface ConversationHistoryHost {
|
|
|
120
125
|
* A hold {@link ConversationHistoryHost.holdRun} took.
|
|
121
126
|
*
|
|
122
127
|
* It says nothing about whether the restore that took it was stopped. A
|
|
123
|
-
* restore asks its generation that, because a
|
|
124
|
-
*
|
|
128
|
+
* restore asks its generation that, because a host's send that waited on a
|
|
129
|
+
* restore with no run to resume takes the hold over from it, and a restore
|
|
130
|
+
* stopped by a newer one finds that one's hold in its place -- see
|
|
125
131
|
* {@link ConversationHistory.rehydrate}.
|
|
126
132
|
*/
|
|
127
133
|
interface RunHold {
|
|
@@ -132,14 +138,6 @@ interface RunHold {
|
|
|
132
138
|
readonly release: () => void;
|
|
133
139
|
}
|
|
134
140
|
|
|
135
|
-
/**
|
|
136
|
-
* What a restore with no run to resume holds: nothing, so it has nothing to
|
|
137
|
-
* let go of. Every restore awaits the store, the built-in one included, and
|
|
138
|
-
* holding all of them would refuse a host's send made as the element connects.
|
|
139
|
-
* Only a restore that is about to start a run has one to collide with.
|
|
140
|
-
*/
|
|
141
|
-
const UNHELD: RunHold = Object.freeze({ release: () => {} });
|
|
142
|
-
|
|
143
141
|
/**
|
|
144
142
|
* The conversation a restore set out to restore: which restore, by its
|
|
145
143
|
* generation, and the store and thread it reads.
|
|
@@ -201,6 +199,17 @@ export class ConversationHistory {
|
|
|
201
199
|
* reaches it. `null` when none is running.
|
|
202
200
|
*/
|
|
203
201
|
#continuation: AgUiClient | null = null;
|
|
202
|
+
/**
|
|
203
|
+
* Settles what a host's send waits on while the restore now current, one
|
|
204
|
+
* with no run to resume, loads and replays; `null` when no such restore is
|
|
205
|
+
* under way. Called when that restore ends, and as soon as it is stopped by
|
|
206
|
+
* anything that moves the generation, so a send waiting on a conversation
|
|
207
|
+
* that is no longer on screen hears so at once rather than when an
|
|
208
|
+
* abandoned load answers.
|
|
209
|
+
*
|
|
210
|
+
* Also what tells a pick refused for a load from one refused for a run.
|
|
211
|
+
*/
|
|
212
|
+
#loading: (() => void) | null = null;
|
|
204
213
|
|
|
205
214
|
constructor(host: ConversationHistoryHost) {
|
|
206
215
|
this.#host = host;
|
|
@@ -256,6 +265,19 @@ export class ConversationHistory {
|
|
|
256
265
|
// being left into the new one once the store answered, and handed it to the
|
|
257
266
|
// new chat's client as the history its first turn would carry.
|
|
258
267
|
this.#generation += 1;
|
|
268
|
+
// A host's send waiting on that restore is addressed to the conversation
|
|
269
|
+
// just cleared. The element's Stop has let go of it already; this wakes it
|
|
270
|
+
// to find so, rather than leaving it waiting on a load nobody wants -- for
|
|
271
|
+
// good, if that load never answers. The drop tests in
|
|
272
|
+
// `ag_ui_chat_send_during_restore.test.ts` hang without it.
|
|
273
|
+
this.#endWait();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Settle what waits on the restore now loading, if anything does, and forget it. */
|
|
277
|
+
#endWait(): void {
|
|
278
|
+
const end = this.#loading;
|
|
279
|
+
this.#loading = null;
|
|
280
|
+
end?.();
|
|
259
281
|
}
|
|
260
282
|
|
|
261
283
|
/** Delete the active thread if nothing was ever sent in it. */
|
|
@@ -401,7 +423,15 @@ export class ConversationHistory {
|
|
|
401
423
|
// closed the panel before this ran. The typed turn stays where it is --
|
|
402
424
|
// it is what the user wants sent once the run is done -- and the caret
|
|
403
425
|
// goes back to it, where Escape stops the run.
|
|
404
|
-
|
|
426
|
+
//
|
|
427
|
+
// A restore with no run to resume holds the composer too, and there is
|
|
428
|
+
// no answer to wait for and nothing Escape can stop, so it says the
|
|
429
|
+
// conversation is still loading instead. A restore that will resume a
|
|
430
|
+
// run keeps the running wording: what it is loading is that run.
|
|
431
|
+
const strings = this.#host.strings();
|
|
432
|
+
this.#refuse(
|
|
433
|
+
this.#loading === null ? strings.continueWhileRunning : strings.continueWhileLoading,
|
|
434
|
+
);
|
|
405
435
|
return;
|
|
406
436
|
}
|
|
407
437
|
const content = this.#host.input.value.trim();
|
|
@@ -553,16 +583,30 @@ export class ConversationHistory {
|
|
|
553
583
|
* exclusion is held by "resumes with the landed page's result, and its card says
|
|
554
584
|
* so" in `ag_ui_chat_reload_mid_run.test.ts`.
|
|
555
585
|
*
|
|
556
|
-
* **
|
|
557
|
-
*
|
|
558
|
-
* remote store that is a real request, and a send, a Retry or a pick
|
|
559
|
-
* was refused by nothing: each built the conversation's client before
|
|
560
|
-
* conversation had loaded, so a send went out carrying none of it
|
|
561
|
-
* first save replaced the stored one, and the
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
586
|
+
* **Every restore holds the composer from before the load.** Until the
|
|
587
|
+
* store answers, the conversation's client can only be seeded from nothing.
|
|
588
|
+
* With a remote store that is a real request, and a send, a Retry or a pick
|
|
589
|
+
* in it was refused by nothing: each built the conversation's client before
|
|
590
|
+
* the conversation had loaded, so a send went out carrying none of it, its
|
|
591
|
+
* first save replaced the stored one, and the replay then drew the stored
|
|
592
|
+
* conversation under the new turn. The client stayed cached without it, so
|
|
593
|
+
* every later turn went out without it too. So the composer is held as a
|
|
594
|
+
* send holds it: what was typed parks and goes out after the replay, and a
|
|
595
|
+
* Retry and a pick are refused -- a Retry has nothing on screen to retry, and
|
|
596
|
+
* after the load it would re-ask the previous visit's last question.
|
|
597
|
+
*
|
|
598
|
+
* **A host's send waits for a restore with no run to resume.** It is
|
|
599
|
+
* usually a prompt fired as the element mounts, which no host could time
|
|
600
|
+
* against a load nothing announces, and refusing it would lose the turn.
|
|
601
|
+
* So it waits for this restore to end, taking the hold over as it does,
|
|
602
|
+
* and then sends with the conversation drawn above it; what the built-in
|
|
603
|
+
* Send parked goes out after its run. Stopped while it waits, it is dropped,
|
|
604
|
+
* as a send stopped by its own submit listener is.
|
|
605
|
+
*
|
|
606
|
+
* **A restore that resumes refuses a host's send instead.** The resume is a
|
|
607
|
+
* run, and the restore ends by starting it, so a send that waited would only
|
|
608
|
+
* reach that run and be refused there. The resume gives way to nothing: what
|
|
609
|
+
* was typed parks behind the resumed run and goes out after its answer.
|
|
566
610
|
*
|
|
567
611
|
* The other way round, the resume standing down for the send, was rejected.
|
|
568
612
|
* The send that got in first had already gone out without the conversation,
|
|
@@ -575,8 +619,8 @@ export class ConversationHistory {
|
|
|
575
619
|
* page can each come while the store answers, and from host code the restore
|
|
576
620
|
* runs itself: the activity and tool renderers each replayed message draws
|
|
577
621
|
* through, and `navigationResult`. From then on it draws nothing more and
|
|
578
|
-
* resumes nothing. The hold cannot say so, because a
|
|
579
|
-
*
|
|
622
|
+
* resumes nothing. The hold cannot say so, because a host's send that
|
|
623
|
+
* waited may hold it by then. So the restore asks its generation, which clearing
|
|
580
624
|
* the conversation moves on as a newer restore does, and asks it after the
|
|
581
625
|
* load, after each replayed message -- as a Retry asks after each one -- and
|
|
582
626
|
* once more before the resume. Stopped by anything but the element leaving
|
|
@@ -584,10 +628,6 @@ export class ConversationHistory {
|
|
|
584
628
|
* for the run it would have resumed -- unless what replaced it is a restore
|
|
585
629
|
* of the same conversation, still current, which answers for that run
|
|
586
630
|
* instead; see `#forgetStopped`.
|
|
587
|
-
*
|
|
588
|
-
* What a restore with no run to resume still does not do is refuse a send
|
|
589
|
-
* made while it loads. That send goes out without the stored conversation,
|
|
590
|
-
* and its save replaces it.
|
|
591
631
|
*/
|
|
592
632
|
async rehydrate(): Promise<void> {
|
|
593
633
|
// Claimed before awaiting, and checked by `#restoring` wherever the restore
|
|
@@ -618,7 +658,22 @@ export class ConversationHistory {
|
|
|
618
658
|
// `#claimedByCurrent`.
|
|
619
659
|
this.#claim = { generation, store, threadId };
|
|
620
660
|
const checkpoint = store.loadCheckpoint(threadId);
|
|
621
|
-
|
|
661
|
+
// What a host's send waits on, for a restore with no run to resume, and
|
|
662
|
+
// the hold either kind takes. Taken before the load for the same reason
|
|
663
|
+
// the checkpoint is read before it: the load is the window.
|
|
664
|
+
//
|
|
665
|
+
// No earlier wait is left to settle here: every restore but the first
|
|
666
|
+
// follows a clear of the conversation on screen -- a reload, a thread
|
|
667
|
+
// switch, a handover, the element coming back -- and `forgetRestored`
|
|
668
|
+
// settled and forgot it then. So a restore that will resume finds none.
|
|
669
|
+
const restored =
|
|
670
|
+
checkpoint === null
|
|
671
|
+
? new Promise<void>((resolve) => {
|
|
672
|
+
this.#loading = resolve;
|
|
673
|
+
})
|
|
674
|
+
: null;
|
|
675
|
+
const waiting = this.#loading;
|
|
676
|
+
const hold = this.#host.holdRun(restored);
|
|
622
677
|
try {
|
|
623
678
|
// Held while the store answers. A remote store answers after first paint,
|
|
624
679
|
// and a conversation it is still fetching is more likely to have messages
|
|
@@ -684,7 +739,21 @@ export class ConversationHistory {
|
|
|
684
739
|
// the store rejected the load, or the store or the host threw on the way
|
|
685
740
|
// to the resume -- and then this is what sends a turn the composer parked
|
|
686
741
|
// meanwhile, since no run is going to settle and send it.
|
|
742
|
+
//
|
|
743
|
+
// A host's send that waited on this restore has taken the hold over, so
|
|
744
|
+
// the release does nothing and the send goes first; what was parked goes
|
|
745
|
+
// out after its run. Woken only after the replay, here, rather than when
|
|
746
|
+
// the store answers, or its turn would be drawn above the conversation.
|
|
747
|
+
// Only this restore's wait: one that was stopped was woken by what
|
|
748
|
+
// stopped it, and the wait now current, if any, is a newer restore's.
|
|
749
|
+
// Waking that one here sent its turn before its conversation was drawn,
|
|
750
|
+
// which "drops the waiting send on a drawer switch, and a send then
|
|
751
|
+
// carries the picked thread" in `ag_ui_chat_send_during_restore.test.ts`
|
|
752
|
+
// holds.
|
|
687
753
|
hold.release();
|
|
754
|
+
if (this.#loading === waiting) {
|
|
755
|
+
this.#endWait();
|
|
756
|
+
}
|
|
688
757
|
}
|
|
689
758
|
}
|
|
690
759
|
|
package/src/ui/ui_strings.ts
CHANGED
|
@@ -120,6 +120,11 @@ export interface UiStrings {
|
|
|
120
120
|
continueNeedsTurn: string;
|
|
121
121
|
/** Composer hint when a run continuation was picked while a run is in flight. */
|
|
122
122
|
continueWhileRunning: string;
|
|
123
|
+
/**
|
|
124
|
+
* Composer hint when a run continuation was picked while the stored
|
|
125
|
+
* conversation is still loading.
|
|
126
|
+
*/
|
|
127
|
+
continueWhileLoading: string;
|
|
123
128
|
|
|
124
129
|
// ── Composer ────────────────────────────────────────────────────────────────
|
|
125
130
|
/** `aria-label` of the message textarea. */
|
|
@@ -388,6 +393,7 @@ export const DEFAULT_UI_STRINGS: UiStrings = {
|
|
|
388
393
|
notConnected: "This chat isn’t connected to an agent, so the message wasn’t sent.",
|
|
389
394
|
continueNeedsTurn: "Type the next turn in the composer first, then pick a run to continue.",
|
|
390
395
|
continueWhileRunning: "Wait for the current answer or stop it, then pick a run to continue.",
|
|
396
|
+
continueWhileLoading: "Wait for the conversation to finish loading, then pick a run to continue.",
|
|
391
397
|
skillNeeds: "“{title}” needs {fields} — fill it in below, then send.",
|
|
392
398
|
|
|
393
399
|
message: "Message",
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION: string = "0.
|
|
1
|
+
export const VERSION: string = "0.43.0";
|