@artooi/ag-ui-web-component 0.41.2 → 0.42.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 +205 -1
- package/README.md +80 -20
- package/dist/ag-ui-web-component.bundle.js +78 -75
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +8 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts +17 -5
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +20 -7
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/storage_scope.d.ts +14 -4
- package/dist/core/storage_scope.d.ts.map +1 -1
- package/dist/index.js +2027 -1110
- package/dist/index.js.map +4 -4
- package/dist/tools/tool_dispatch.d.ts +13 -0
- package/dist/tools/tool_dispatch.d.ts.map +1 -1
- package/dist/ui/composer/voice_input.d.ts +10 -4
- package/dist/ui/composer/voice_input.d.ts.map +1 -1
- package/dist/ui/history/conversation_history.d.ts +73 -1
- package/dist/ui/history/conversation_history.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +8 -1
- package/src/core/ag_ui_chat.ts +417 -59
- package/src/core/agui_client.ts +55 -12
- package/src/core/storage_scope.ts +21 -8
- package/src/tools/tool_dispatch.ts +114 -3
- package/src/ui/composer/voice_input.ts +35 -4
- package/src/ui/history/conversation_history.ts +351 -55
- package/src/version.ts +1 -1
package/src/core/agui_client.ts
CHANGED
|
@@ -55,8 +55,11 @@ export interface ToolExecution {
|
|
|
55
55
|
* Executes a frontend tool call.
|
|
56
56
|
*
|
|
57
57
|
* Returns the {@link ToolExecution} to post back to the agent, or `null` when
|
|
58
|
-
* the call is not a frontend tool the host owns
|
|
59
|
-
* already executed, which the client must not
|
|
58
|
+
* there is nothing to post: the call is not a frontend tool the host owns (a
|
|
59
|
+
* server-side tool the server already executed, which the client must not
|
|
60
|
+
* re-run), or its run was stopped or handed over while the call waited. A
|
|
61
|
+
* stopped call is left unanswered here, like the calls after it in the round,
|
|
62
|
+
* and the next request answers it as not finished.
|
|
60
63
|
*/
|
|
61
64
|
export type ExecuteTool = (call: AgUiToolCall) => Promise<ToolExecution | null>;
|
|
62
65
|
|
|
@@ -317,8 +320,10 @@ export class AgUiClient {
|
|
|
317
320
|
readonly #unfinishedMessage: string;
|
|
318
321
|
readonly #declinedMessage: string;
|
|
319
322
|
readonly #maxToolRounds: number;
|
|
320
|
-
// Set by cancel(); reset
|
|
321
|
-
// a cancel between frontend-tool
|
|
323
|
+
// Set by cancel(); reset as send() or resume() begins, before either runs
|
|
324
|
+
// any host code. Checked by the loop so a cancel between frontend-tool
|
|
325
|
+
// rounds doesn't start another round, and again just before each request,
|
|
326
|
+
// so a cancel from host code the round itself runs is not sent anyway.
|
|
322
327
|
#cancelled = false;
|
|
323
328
|
|
|
324
329
|
constructor(config: AgUiClientConfig) {
|
|
@@ -388,6 +393,12 @@ export class AgUiClient {
|
|
|
388
393
|
* {@link messageAttachments}.
|
|
389
394
|
*/
|
|
390
395
|
async send(content: string, attachments: readonly AttachmentRef[] = []): Promise<void> {
|
|
396
|
+
// A new interaction, so the Stop of the one before it is forgotten here --
|
|
397
|
+
// ahead of the save below, which is the host's store. Forgotten as the run
|
|
398
|
+
// began instead, a Stop that store made (a new chat started from the save)
|
|
399
|
+
// was erased before anything read it, and the turn went out regardless,
|
|
400
|
+
// into the conversation being left and with all of its history.
|
|
401
|
+
this.#cancelled = false;
|
|
391
402
|
// In `metadata` because it is the one field on a message `@ag-ui/client`
|
|
392
403
|
// declares open by key. 1.0 strips every undeclared key from the input it
|
|
393
404
|
// sends, so refs at the top level of the message -- where every release
|
|
@@ -410,11 +421,14 @@ export class AgUiClient {
|
|
|
410
421
|
* can be asked again.
|
|
411
422
|
*
|
|
412
423
|
* Returns the retained history, or `null` when there is nothing to retry (no
|
|
413
|
-
* user message has been sent yet).
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
424
|
+
* user message has been sent yet). The shortened history is saved before it
|
|
425
|
+
* replaces the current one, so a save that throws changes nothing and the
|
|
426
|
+
* throw propagates.
|
|
427
|
+
*
|
|
428
|
+
* **Truncates only** -- the caller re-renders from the returned list and then
|
|
429
|
+
* calls {@link resume}, because the transcript belongs to the element and a
|
|
430
|
+
* client that reached into it would own two things. Running here instead
|
|
431
|
+
* would stream the new answer in underneath the old one.
|
|
418
432
|
*
|
|
419
433
|
* Re-running answers the question the agent was last asked, rather than
|
|
420
434
|
* telling it its answer was wrong, which is what makes the result a
|
|
@@ -439,8 +453,15 @@ export class AgUiClient {
|
|
|
439
453
|
return null;
|
|
440
454
|
}
|
|
441
455
|
const kept = messages.slice(0, lastUser + 1);
|
|
456
|
+
// Saved before the history is shortened, so a store that throws leaves it
|
|
457
|
+
// as it was and the throw reaches the caller, as a refused save does from
|
|
458
|
+
// send(). Shortened first, the refusal left this client without the answer
|
|
459
|
+
// while the caller's transcript still showed it, and the next request then
|
|
460
|
+
// asked a follow-up about something the agent was never told it had said.
|
|
461
|
+
// Handed over directly rather than through #persist, which saves the list
|
|
462
|
+
// the agent holds, because the agent does not hold this one yet.
|
|
463
|
+
this.#onPersist(kept);
|
|
442
464
|
this.#agent.setMessages(kept);
|
|
443
|
-
this.#persist();
|
|
444
465
|
return kept;
|
|
445
466
|
}
|
|
446
467
|
|
|
@@ -450,6 +471,10 @@ export class AgUiClient {
|
|
|
450
471
|
* message; it continues the conversation already in history.
|
|
451
472
|
*/
|
|
452
473
|
async resume(): Promise<void> {
|
|
474
|
+
// A new interaction, as in send(). No host code runs between here and the
|
|
475
|
+
// loop, so for a resume this is the same moment the run used to forget a
|
|
476
|
+
// Stop at; it moved here so that both ways in forget it in one place.
|
|
477
|
+
this.#cancelled = false;
|
|
453
478
|
await this.#run();
|
|
454
479
|
}
|
|
455
480
|
|
|
@@ -465,6 +490,13 @@ export class AgUiClient {
|
|
|
465
490
|
* disconnect). Safe to call with no run in flight. Partial text already
|
|
466
491
|
* streamed stays in history; {@link AgUiClientHandlers.onCancelled} fires
|
|
467
492
|
* instead of `onError`, and `onSettled` still follows.
|
|
493
|
+
*
|
|
494
|
+
* Also stops a {@link send} or {@link resume} whose request has not been made
|
|
495
|
+
* yet, when host code it runs on the way calls this: the save of the turn,
|
|
496
|
+
* the tool and context providers, the save answering a call left open. No
|
|
497
|
+
* request is made then, and `onCancelled` and `onSettled` fire as for any
|
|
498
|
+
* other Stop. A cancel with nothing in flight is forgotten when the next
|
|
499
|
+
* send or resume begins.
|
|
468
500
|
*/
|
|
469
501
|
cancel(): void {
|
|
470
502
|
this.#cancelled = true;
|
|
@@ -472,7 +504,6 @@ export class AgUiClient {
|
|
|
472
504
|
}
|
|
473
505
|
|
|
474
506
|
async #run(): Promise<void> {
|
|
475
|
-
this.#cancelled = false;
|
|
476
507
|
try {
|
|
477
508
|
await this.#runLoop();
|
|
478
509
|
// `@ag-ui/client` filters abort errors inside `runAgent` (it resolves
|
|
@@ -637,7 +668,10 @@ export class AgUiClient {
|
|
|
637
668
|
let resuming: ReadonlySet<string> = new Set();
|
|
638
669
|
for (let round = 0; round < this.#maxToolRounds; round += 1) {
|
|
639
670
|
// A cancel during the previous round's tool execution lands here: the
|
|
640
|
-
// running handler completed, but no further round starts.
|
|
671
|
+
// running handler completed, but no further round starts. The check
|
|
672
|
+
// before the request below would stop the round as well; this one keeps
|
|
673
|
+
// a stopped loop from running the providers and the store for a round
|
|
674
|
+
// it will not send.
|
|
641
675
|
if (this.#cancelled) {
|
|
642
676
|
return;
|
|
643
677
|
}
|
|
@@ -651,6 +685,15 @@ export class AgUiClient {
|
|
|
651
685
|
params.resume = resume;
|
|
652
686
|
}
|
|
653
687
|
this.#answerUnansweredCalls(resuming);
|
|
688
|
+
// Everything above since the check at the top is host code -- the tool
|
|
689
|
+
// and context providers, and the store saving the answers just made -- and
|
|
690
|
+
// any of it can be what stopped the run, by starting a new chat or
|
|
691
|
+
// changing the principal. `@ag-ui/client` makes a fresh abort controller
|
|
692
|
+
// for each run, so the abort `cancel()` made in that time reached
|
|
693
|
+
// nothing, and only this check keeps the request from going out.
|
|
694
|
+
if (this.#cancelled) {
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
654
697
|
await this.#agent.runAgent(params, this.#buildSubscriber(pending, runState));
|
|
655
698
|
resume = undefined;
|
|
656
699
|
resuming = new Set();
|
|
@@ -53,9 +53,12 @@ export class StorageScope {
|
|
|
53
53
|
// remounts, but re-resolves if the host answers the warning with an `id`.
|
|
54
54
|
#generatedNs = "";
|
|
55
55
|
#generatedFor = "";
|
|
56
|
-
// The `sessionStorage`-backed store, which the element
|
|
57
|
-
// on a principal change
|
|
58
|
-
//
|
|
56
|
+
// The `sessionStorage`-backed store this scope handed out, which the element
|
|
57
|
+
// may therefore re-scope on a principal change -- while it is still the store
|
|
58
|
+
// in use, which rescopeStore asks, since a host can assign its own at any
|
|
59
|
+
// time. `null` until connecting has met the built-in store at all. A store of
|
|
60
|
+
// the host's own kind is never held here: the element does not know its
|
|
61
|
+
// keying and must not guess at it.
|
|
59
62
|
#builtinStore: SessionStorageStore | null = null;
|
|
60
63
|
|
|
61
64
|
constructor(host: StorageScopeHost) {
|
|
@@ -115,12 +118,22 @@ export class StorageScope {
|
|
|
115
118
|
}
|
|
116
119
|
|
|
117
120
|
/**
|
|
118
|
-
* Rebuild the built-in store under `namespace`, or `null`
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
+
* Rebuild the built-in store under `namespace`, or `null` when `current` --
|
|
122
|
+
* the store the element is using now, unwrapped from any remote it wrapped
|
|
123
|
+
* it in -- is not the one this scope handed out.
|
|
124
|
+
*
|
|
125
|
+
* Asked of the store in use rather than of what connecting remembered,
|
|
126
|
+
* because the two part company the moment a host assigns a store of its own
|
|
127
|
+
* to a connected element. Remembering was enough to tell a host's store from
|
|
128
|
+
* the built-in one at connect; after it, the built-in one was still
|
|
129
|
+
* remembered, so a key change replaced the host's store with a fresh
|
|
130
|
+
* `sessionStorage` one -- and a host that had chosen to keep message bodies
|
|
131
|
+
* off the client found every one of them cached in the tab again. A store
|
|
132
|
+
* the element did not make holds its data somewhere the element cannot see,
|
|
133
|
+
* and has to scope itself.
|
|
121
134
|
*/
|
|
122
|
-
rescopeStore(namespace: string): SessionStorageStore | null {
|
|
123
|
-
if (this.#builtinStore
|
|
135
|
+
rescopeStore(current: ClientConversationStore, namespace: string): SessionStorageStore | null {
|
|
136
|
+
if (current !== this.#builtinStore) {
|
|
124
137
|
return null;
|
|
125
138
|
}
|
|
126
139
|
this.#builtinStore = new SessionStorageStore(namespace);
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
requestConfirmation,
|
|
15
15
|
} from "../ui/interrupts/confirmation_card.js";
|
|
16
16
|
import type { PendingDecision } from "../ui/interrupts/pending_decision.js";
|
|
17
|
+
import type { ToolCallCard } from "../ui/progress/tool_call_card.js";
|
|
17
18
|
import type { Transcript } from "../ui/transcript/transcript.js";
|
|
18
19
|
import type { UiStrings } from "../ui/ui_strings.js";
|
|
19
20
|
import type { ClientTool } from "./client_tool_registry.js";
|
|
@@ -56,6 +57,13 @@ export interface ToolDispatchHost {
|
|
|
56
57
|
readonly conversationStore: () => ClientConversationStore;
|
|
57
58
|
/** The active thread's id. */
|
|
58
59
|
readonly threadId: () => string;
|
|
60
|
+
/**
|
|
61
|
+
* The element's current tenure: an identity it replaces on every handover
|
|
62
|
+
* from one principal to another, and never otherwise. Only ever compared, so
|
|
63
|
+
* a call can tell whether the principal it was dispatched for is still the
|
|
64
|
+
* one the element serves.
|
|
65
|
+
*/
|
|
66
|
+
readonly tenure: () => object;
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
/**
|
|
@@ -118,6 +126,12 @@ export class ToolDispatch {
|
|
|
118
126
|
* Execute one frontend tool call the round produced, against its own card:
|
|
119
127
|
* refused when the page moved under the round, asked about when a rule gates
|
|
120
128
|
* it, and settled with what the handler returned or threw.
|
|
129
|
+
*
|
|
130
|
+
* Every await below is a place the run can end, so each is followed by a
|
|
131
|
+
* check that it has not. A Stop while the host's predicate is pending ends
|
|
132
|
+
* the call there, and one while the card is open declines it. A handover
|
|
133
|
+
* during any of the three leaves the call with no transcript to draw in and
|
|
134
|
+
* no store to write to, so it does neither.
|
|
121
135
|
*/
|
|
122
136
|
async execute(call: AgUiToolCall): Promise<ToolExecution | null> {
|
|
123
137
|
// A skill load already rendered as a notice on the stream; it is never a
|
|
@@ -126,6 +140,15 @@ export class ToolDispatch {
|
|
|
126
140
|
if (skillNameFrom(call) !== null) {
|
|
127
141
|
return null;
|
|
128
142
|
}
|
|
143
|
+
// Fixed here, as the call is dispatched, rather than read when they are
|
|
144
|
+
// used. The client calls this in the same task as its own check that the
|
|
145
|
+
// run was not stopped, so these are the run's principal and thread; read
|
|
146
|
+
// after the handler instead, they were whoever was current by then. A
|
|
147
|
+
// navigating handler's failure cleared the checkpoint of the thread New
|
|
148
|
+
// chat had just moved to, leaving its own behind, and across a handover
|
|
149
|
+
// it wrote into the next principal's store.
|
|
150
|
+
const tenure = this.#host.tenure();
|
|
151
|
+
const threadId = this.#host.threadId();
|
|
129
152
|
const card = this.#host.transcript.cardFor(call);
|
|
130
153
|
this.#host.transcript.forgetCard(call.id);
|
|
131
154
|
// Kept after the card leaves the awaiting cards: a tool that renders into the
|
|
@@ -180,7 +203,23 @@ export class ToolDispatch {
|
|
|
180
203
|
// lives only in the DOM, and the DOM is what a reload throws away.
|
|
181
204
|
return { content: `Error: ${message}`, error: message, outcome: TOOL_OUTCOME.FAILED };
|
|
182
205
|
}
|
|
206
|
+
// The run is suspended on the host's predicate as much as on a card, and a
|
|
207
|
+
// predicate is free to take as long as one: it may ask a server. So the
|
|
208
|
+
// wait is opened for it the same way, and a Stop -- or a handover, which
|
|
209
|
+
// stops the run first -- aborts it. Unchecked, a predicate that answered
|
|
210
|
+
// after the Stop opened its card or ran its handler for a run the user had
|
|
211
|
+
// ended, and after a handover it ran the previous principal's handler on
|
|
212
|
+
// the next principal's page.
|
|
213
|
+
//
|
|
214
|
+
// Held by "draws no card when the predicate answers after Stop", and for
|
|
215
|
+
// the handover by "does not run the previous principal's handler after a
|
|
216
|
+
// handover", in tool_dispatch.test.ts.
|
|
217
|
+
const asking = this.#host.decision.open();
|
|
183
218
|
const rule = await this.#confirmationRule(call, tool);
|
|
219
|
+
this.#host.decision.close();
|
|
220
|
+
if (asking.aborted) {
|
|
221
|
+
return this.#abandoned(card);
|
|
222
|
+
}
|
|
184
223
|
if (rule === "unanswered") {
|
|
185
224
|
// Settled as a refusal and returned as one, so the run carries on to its
|
|
186
225
|
// next round the way it does after a decline and the agent can say what
|
|
@@ -216,6 +255,17 @@ export class ToolDispatch {
|
|
|
216
255
|
this.#host.transcript.follow();
|
|
217
256
|
const accepted = await decision;
|
|
218
257
|
this.#host.decision.close();
|
|
258
|
+
// A handover while the card was open declines it, as any Stop does, but
|
|
259
|
+
// the transcript the decline would be drawn into is the next principal's
|
|
260
|
+
// by now, and the client the refusal goes to writes nothing for a tenure
|
|
261
|
+
// that has ended. Unchecked, the pending indicator the decline shows was
|
|
262
|
+
// left in the next principal's empty transcript with nothing to remove it.
|
|
263
|
+
//
|
|
264
|
+
// Held by "draws nothing for the next principal when a handover declines
|
|
265
|
+
// the card" in tool_dispatch.test.ts.
|
|
266
|
+
if (this.#host.tenure() !== tenure) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
219
269
|
card.recordDecision(accepted ? "approved" : "declined");
|
|
220
270
|
if (!accepted) {
|
|
221
271
|
const message = this.#host.strings().declinedAction;
|
|
@@ -227,6 +277,18 @@ export class ToolDispatch {
|
|
|
227
277
|
// the user had explicitly refused.
|
|
228
278
|
return { content: message, outcome: TOOL_OUTCOME.DENIED };
|
|
229
279
|
}
|
|
280
|
+
// Answered yes, and then stopped before that answer was heard: a click
|
|
281
|
+
// on Confirm and a Stop in the same task, so the card had already
|
|
282
|
+
// resolved by the time the abort reached it. The person agreed to the
|
|
283
|
+
// call, so it is not declined; the run it belonged to is over, so it does
|
|
284
|
+
// not run either. The server-side gate treats approve-then-Stop the same
|
|
285
|
+
// way.
|
|
286
|
+
//
|
|
287
|
+
// Held by "does not run a call confirmed in the same task as Stop" in
|
|
288
|
+
// tool_dispatch.test.ts.
|
|
289
|
+
if (signal.aborted) {
|
|
290
|
+
return this.#abandoned(card);
|
|
291
|
+
}
|
|
230
292
|
}
|
|
231
293
|
// A navigating tool reloads only without a client-side router; with a
|
|
232
294
|
// host `navigate()` (SPA) it routes in-page and the loop just continues.
|
|
@@ -235,12 +297,30 @@ export class ToolDispatch {
|
|
|
235
297
|
// Checkpoint before the handler reloads the page; the history (incl.
|
|
236
298
|
// this tool call) was already persisted when the run that produced it
|
|
237
299
|
// settled. The result is supplied on the next mount via the resume path.
|
|
238
|
-
|
|
300
|
+
//
|
|
301
|
+
// Nothing has been awaited since the last check that the run is still
|
|
302
|
+
// going, so the live thread is the dispatch one here too. Written under
|
|
303
|
+
// the dispatch thread all the same, so the clear below is certain to
|
|
304
|
+
// remove what this wrote.
|
|
305
|
+
this.#host.conversationStore().saveCheckpoint(threadId, { toolCallId: call.id });
|
|
239
306
|
}
|
|
240
307
|
try {
|
|
241
308
|
// The call id lets a handler that renders into the transcript find its
|
|
242
309
|
// own card; handlers that only act on the page ignore it.
|
|
243
310
|
const result = await tool.handler(call.args, call.id);
|
|
311
|
+
// A handler cannot be aborted, and a plain Stop while it ran keeps its
|
|
312
|
+
// result: the action happened on the page, and the agent is told so on
|
|
313
|
+
// the next request. A handover is different. The transcript this would
|
|
314
|
+
// draw into is the next principal's now, and the client the result goes
|
|
315
|
+
// to writes nothing for a tenure that has ended, so the result has no
|
|
316
|
+
// one left to reach. Unchecked, the pending indicator was drawn into the
|
|
317
|
+
// next principal's empty transcript, where nothing would ever remove it.
|
|
318
|
+
//
|
|
319
|
+
// Held by "draws nothing for the next principal when a handler resolves
|
|
320
|
+
// after a handover" in tool_dispatch.test.ts.
|
|
321
|
+
if (this.#host.tenure() !== tenure) {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
244
324
|
// Drawn from the arguments rather than the result, so the live path and
|
|
245
325
|
// the replay path render the same thing from the same input.
|
|
246
326
|
if (tool.render !== undefined) {
|
|
@@ -255,9 +335,20 @@ export class ToolDispatch {
|
|
|
255
335
|
this.#host.transcript.showPending();
|
|
256
336
|
return { content };
|
|
257
337
|
} catch (error) {
|
|
338
|
+
// The same handover check as on success, and here it also guards a
|
|
339
|
+
// write: the store is read live, and after a handover it is the next
|
|
340
|
+
// principal's, so the clear below removed their checkpoint rather than
|
|
341
|
+
// this call's -- which the handover's purge had already removed.
|
|
342
|
+
//
|
|
343
|
+
// Held by "leaves the next principal's checkpoint alone when a handler
|
|
344
|
+
// fails after a handover" in tool_dispatch.test.ts.
|
|
345
|
+
if (this.#host.tenure() !== tenure) {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
258
348
|
if (navigates) {
|
|
259
|
-
// The navigation never happened; drop the dangling checkpoint
|
|
260
|
-
|
|
349
|
+
// The navigation never happened; drop the dangling checkpoint, from
|
|
350
|
+
// the thread it was written to.
|
|
351
|
+
this.#host.conversationStore().saveCheckpoint(threadId, null);
|
|
261
352
|
}
|
|
262
353
|
// The handler's own message, verbatim, in two places at once: the card,
|
|
263
354
|
// which the user sees, and the tool result, which goes to the endpoint,
|
|
@@ -479,6 +570,26 @@ export class ToolDispatch {
|
|
|
479
570
|
return isDestructive(tool.parameters) ? "destructive" : null;
|
|
480
571
|
}
|
|
481
572
|
|
|
573
|
+
/**
|
|
574
|
+
* Settle a call whose run ended while it waited, and give the loop nothing.
|
|
575
|
+
*
|
|
576
|
+
* The card is settled here because nothing else will: dispatch took it out
|
|
577
|
+
* of the end-of-run sweep before it waited, so left alone it read "running…"
|
|
578
|
+
* for good. It says the call did not finish, which is true -- nothing ran and
|
|
579
|
+
* nobody refused it.
|
|
580
|
+
*
|
|
581
|
+
* `null` rather than a result, because a stopped run has no next round to
|
|
582
|
+
* send one in. The loop moves on and finds the run stopped, and the call is
|
|
583
|
+
* left unanswered like every call after it in the round, so the client
|
|
584
|
+
* answers it as not finished on the next request -- in the same words as
|
|
585
|
+
* this card. After a handover the card is in a transcript already cleared,
|
|
586
|
+
* and settling it there is seen by no one.
|
|
587
|
+
*/
|
|
588
|
+
#abandoned(card: ToolCallCard): null {
|
|
589
|
+
card.settle(TOOL_CALL_STATUS.INTERRUPTED, this.#host.strings().callNotFinished);
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
|
|
482
593
|
/**
|
|
483
594
|
* Whether the page moved since the current round's context was built.
|
|
484
595
|
*
|
|
@@ -100,6 +100,16 @@ export class VoiceInput {
|
|
|
100
100
|
this.#fail(this.#strings.transcriptionFailed);
|
|
101
101
|
return;
|
|
102
102
|
}
|
|
103
|
+
if (this.#disposed) {
|
|
104
|
+
// The permission prompt outlived the control: the element left, or
|
|
105
|
+
// user-key changed, while it was open. Recording now would light the
|
|
106
|
+
// browser's indicator for a button nobody can see or stop, and dispose
|
|
107
|
+
// has already run, so nothing else would ever release these tracks.
|
|
108
|
+
for (const track of stream.getTracks()) {
|
|
109
|
+
track.stop();
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
103
113
|
this.#stream = stream;
|
|
104
114
|
this.#chunks = [];
|
|
105
115
|
this.#hitCap = false;
|
|
@@ -136,10 +146,16 @@ export class VoiceInput {
|
|
|
136
146
|
}
|
|
137
147
|
|
|
138
148
|
/**
|
|
139
|
-
* Tear the control down, for a host element removed mid-recording
|
|
140
|
-
* live `MediaRecorder`, releases the mic
|
|
141
|
-
* indicator clears, and suppresses the
|
|
142
|
-
* disconnected control must not fire `onText` into
|
|
149
|
+
* Tear the control down, for a host element removed mid-recording or handed
|
|
150
|
+
* to the next principal. Stops any live `MediaRecorder`, releases the mic
|
|
151
|
+
* tracks so the browser's recording indicator clears, and suppresses the
|
|
152
|
+
* pending transcription — a disconnected control must not fire `onText` into
|
|
153
|
+
* a detached element.
|
|
154
|
+
*
|
|
155
|
+
* 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 stale
|
|
157
|
+
* continuation from a live one, and a clip already posted when this runs is
|
|
158
|
+
* dropped whenever its answer arrives.
|
|
143
159
|
*/
|
|
144
160
|
dispose(): void {
|
|
145
161
|
this.#disposed = true;
|
|
@@ -160,6 +176,15 @@ export class VoiceInput {
|
|
|
160
176
|
const audio = new Blob(this.#chunks, { type: mimeType || "audio/webm" });
|
|
161
177
|
try {
|
|
162
178
|
const text = await this.#transcribe(audio);
|
|
179
|
+
if (this.#disposed) {
|
|
180
|
+
// Disposed while the clip was in flight. The check above ran before the
|
|
181
|
+
// await and cannot see this: on a change of user-key the host disposes
|
|
182
|
+
// and wires a fresh mic over the same composer, so delivering now
|
|
183
|
+
// would put the previous principal's words in the next one's box.
|
|
184
|
+
// The button is left as it was, too: it is no longer this control's
|
|
185
|
+
// to change.
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
163
188
|
this.#setState("idle");
|
|
164
189
|
if (this.#hitCap) {
|
|
165
190
|
// #setState has just reset the tooltip to the idle label, so this goes
|
|
@@ -188,6 +213,12 @@ export class VoiceInput {
|
|
|
188
213
|
}
|
|
189
214
|
|
|
190
215
|
#fail(message: string): void {
|
|
216
|
+
// Both callers reach here after an await, a permission prompt or a
|
|
217
|
+
// transcription, either of which can settle after dispose. A disposed
|
|
218
|
+
// control has nothing to release and no tooltip to draw.
|
|
219
|
+
if (this.#disposed) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
191
222
|
this.#releaseStream();
|
|
192
223
|
this.#recorder = null;
|
|
193
224
|
this.#setState("idle");
|