@artooi/ag-ui-web-component 0.41.3 → 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 +304 -1
- package/README.md +113 -24
- package/dist/ag-ui-web-component.bundle.js +33 -33
- package/dist/ag-ui-web-component.bundle.js.map +3 -3
- package/dist/constants.d.ts +3 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts +49 -9
- 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 +729 -115
- package/dist/index.js.map +3 -3
- package/dist/tools/tool_dispatch.d.ts +23 -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 +84 -1
- 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/constants.ts +3 -1
- package/src/core/ag_ui_chat.ts +498 -70
- package/src/core/agui_client.ts +55 -12
- package/src/core/storage_scope.ts +21 -8
- package/src/tools/tool_dispatch.ts +153 -6
- package/src/ui/composer/voice_input.ts +79 -14
- package/src/ui/history/conversation_history.ts +392 -56
- package/src/ui/ui_strings.ts +6 -0
- 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,20 @@ 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;
|
|
67
|
+
/**
|
|
68
|
+
* The conversation on screen: an identity the element replaces every time it
|
|
69
|
+
* clears the transcript for another conversation, a handover included. Only
|
|
70
|
+
* ever compared, so a call can tell whether the conversation it was
|
|
71
|
+
* dispatched in is still the one it would draw into.
|
|
72
|
+
*/
|
|
73
|
+
readonly conversation: () => object;
|
|
59
74
|
}
|
|
60
75
|
|
|
61
76
|
/**
|
|
@@ -118,6 +133,15 @@ export class ToolDispatch {
|
|
|
118
133
|
* Execute one frontend tool call the round produced, against its own card:
|
|
119
134
|
* refused when the page moved under the round, asked about when a rule gates
|
|
120
135
|
* it, and settled with what the handler returned or threw.
|
|
136
|
+
*
|
|
137
|
+
* Every await below is a place the run can end, so each is followed by a
|
|
138
|
+
* check that it has not. A Stop while the host's predicate is pending ends
|
|
139
|
+
* the call there, and one while the card is open declines it. A handover
|
|
140
|
+
* during any of the three leaves the call with no transcript to draw in and
|
|
141
|
+
* no store to write to, so it does neither. Any other reset of the
|
|
142
|
+
* conversation -- New chat, a drawer switch, `reload()` -- leaves the call
|
|
143
|
+
* its store and its result but not the transcript, so it still settles and
|
|
144
|
+
* returns, and does not show the run waiting in a conversation it is not in.
|
|
121
145
|
*/
|
|
122
146
|
async execute(call: AgUiToolCall): Promise<ToolExecution | null> {
|
|
123
147
|
// A skill load already rendered as a notice on the stream; it is never a
|
|
@@ -126,6 +150,16 @@ export class ToolDispatch {
|
|
|
126
150
|
if (skillNameFrom(call) !== null) {
|
|
127
151
|
return null;
|
|
128
152
|
}
|
|
153
|
+
// Fixed here, as the call is dispatched, rather than read when they are
|
|
154
|
+
// used. The client calls this in the same task as its own check that the
|
|
155
|
+
// run was not stopped, so these are the run's principal and thread; read
|
|
156
|
+
// after the handler instead, they were whoever was current by then. A
|
|
157
|
+
// navigating handler's failure cleared the checkpoint of the thread New
|
|
158
|
+
// chat had just moved to, leaving its own behind, and across a handover
|
|
159
|
+
// it wrote into the next principal's store.
|
|
160
|
+
const tenure = this.#host.tenure();
|
|
161
|
+
const conversation = this.#host.conversation();
|
|
162
|
+
const threadId = this.#host.threadId();
|
|
129
163
|
const card = this.#host.transcript.cardFor(call);
|
|
130
164
|
this.#host.transcript.forgetCard(call.id);
|
|
131
165
|
// Kept after the card leaves the awaiting cards: a tool that renders into the
|
|
@@ -180,7 +214,23 @@ export class ToolDispatch {
|
|
|
180
214
|
// lives only in the DOM, and the DOM is what a reload throws away.
|
|
181
215
|
return { content: `Error: ${message}`, error: message, outcome: TOOL_OUTCOME.FAILED };
|
|
182
216
|
}
|
|
217
|
+
// The run is suspended on the host's predicate as much as on a card, and a
|
|
218
|
+
// predicate is free to take as long as one: it may ask a server. So the
|
|
219
|
+
// wait is opened for it the same way, and a Stop -- or a handover, which
|
|
220
|
+
// stops the run first -- aborts it. Unchecked, a predicate that answered
|
|
221
|
+
// after the Stop opened its card or ran its handler for a run the user had
|
|
222
|
+
// ended, and after a handover it ran the previous principal's handler on
|
|
223
|
+
// the next principal's page.
|
|
224
|
+
//
|
|
225
|
+
// Held by "draws no card when the predicate answers after Stop", and for
|
|
226
|
+
// the handover by "does not run the previous principal's handler after a
|
|
227
|
+
// handover", in tool_dispatch.test.ts.
|
|
228
|
+
const asking = this.#host.decision.open();
|
|
183
229
|
const rule = await this.#confirmationRule(call, tool);
|
|
230
|
+
this.#host.decision.close();
|
|
231
|
+
if (asking.aborted) {
|
|
232
|
+
return this.#abandoned(card);
|
|
233
|
+
}
|
|
184
234
|
if (rule === "unanswered") {
|
|
185
235
|
// Settled as a refusal and returned as one, so the run carries on to its
|
|
186
236
|
// next round the way it does after a decline and the agent can say what
|
|
@@ -216,17 +266,49 @@ export class ToolDispatch {
|
|
|
216
266
|
this.#host.transcript.follow();
|
|
217
267
|
const accepted = await decision;
|
|
218
268
|
this.#host.decision.close();
|
|
269
|
+
// A handover while the card was open declines it, as any Stop does, but
|
|
270
|
+
// the transcript the decline would be drawn into is the next principal's
|
|
271
|
+
// by now, and the client the refusal goes to writes nothing for a tenure
|
|
272
|
+
// that has ended. Unchecked, the pending indicator the decline shows was
|
|
273
|
+
// left in the next principal's empty transcript with nothing to remove it.
|
|
274
|
+
//
|
|
275
|
+
// Held by "draws nothing for the next principal when a handover declines
|
|
276
|
+
// the card" in tool_dispatch.test.ts.
|
|
277
|
+
if (this.#host.tenure() !== tenure) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
219
280
|
card.recordDecision(accepted ? "approved" : "declined");
|
|
220
281
|
if (!accepted) {
|
|
221
282
|
const message = this.#host.strings().declinedAction;
|
|
222
283
|
card.settle(TOOL_CALL_STATUS.DECLINED, message);
|
|
223
|
-
|
|
284
|
+
// New chat or a drawer switch with the card open declines it too, and
|
|
285
|
+
// the indicator would land in the conversation that replaced this one,
|
|
286
|
+
// whose run is not this one and which nothing would take it down from.
|
|
287
|
+
// Only the drawing is skipped: the refusal is still this run's to send.
|
|
288
|
+
//
|
|
289
|
+
// Held by "draws nothing into the new chat when New chat declines the
|
|
290
|
+
// open card" in tool_dispatch.test.ts.
|
|
291
|
+
if (this.#host.conversation() === conversation) {
|
|
292
|
+
this.#host.transcript.showPending();
|
|
293
|
+
}
|
|
224
294
|
// The one outcome with no error text and no server involvement at all:
|
|
225
295
|
// a person said no in this browser. Nothing else records that, so
|
|
226
296
|
// without the annotation the reload showed a green card for an action
|
|
227
297
|
// the user had explicitly refused.
|
|
228
298
|
return { content: message, outcome: TOOL_OUTCOME.DENIED };
|
|
229
299
|
}
|
|
300
|
+
// Answered yes, and then stopped before that answer was heard: a click
|
|
301
|
+
// on Confirm and a Stop in the same task, so the card had already
|
|
302
|
+
// resolved by the time the abort reached it. The person agreed to the
|
|
303
|
+
// call, so it is not declined; the run it belonged to is over, so it does
|
|
304
|
+
// not run either. The server-side gate treats approve-then-Stop the same
|
|
305
|
+
// way.
|
|
306
|
+
//
|
|
307
|
+
// Held by "does not run a call confirmed in the same task as Stop" in
|
|
308
|
+
// tool_dispatch.test.ts.
|
|
309
|
+
if (signal.aborted) {
|
|
310
|
+
return this.#abandoned(card);
|
|
311
|
+
}
|
|
230
312
|
}
|
|
231
313
|
// A navigating tool reloads only without a client-side router; with a
|
|
232
314
|
// host `navigate()` (SPA) it routes in-page and the loop just continues.
|
|
@@ -235,12 +317,30 @@ export class ToolDispatch {
|
|
|
235
317
|
// Checkpoint before the handler reloads the page; the history (incl.
|
|
236
318
|
// this tool call) was already persisted when the run that produced it
|
|
237
319
|
// settled. The result is supplied on the next mount via the resume path.
|
|
238
|
-
|
|
320
|
+
//
|
|
321
|
+
// Nothing has been awaited since the last check that the run is still
|
|
322
|
+
// going, so the live thread is the dispatch one here too. Written under
|
|
323
|
+
// the dispatch thread all the same, so the clear below is certain to
|
|
324
|
+
// remove what this wrote.
|
|
325
|
+
this.#host.conversationStore().saveCheckpoint(threadId, { toolCallId: call.id });
|
|
239
326
|
}
|
|
240
327
|
try {
|
|
241
328
|
// The call id lets a handler that renders into the transcript find its
|
|
242
329
|
// own card; handlers that only act on the page ignore it.
|
|
243
330
|
const result = await tool.handler(call.args, call.id);
|
|
331
|
+
// A handler cannot be aborted, and a plain Stop while it ran keeps its
|
|
332
|
+
// result: the action happened on the page, and the agent is told so on
|
|
333
|
+
// the next request. A handover is different. The transcript this would
|
|
334
|
+
// draw into is the next principal's now, and the client the result goes
|
|
335
|
+
// to writes nothing for a tenure that has ended, so the result has no
|
|
336
|
+
// one left to reach. Unchecked, the pending indicator was drawn into the
|
|
337
|
+
// next principal's empty transcript, where nothing would ever remove it.
|
|
338
|
+
//
|
|
339
|
+
// Held by "draws nothing for the next principal when a handler resolves
|
|
340
|
+
// after a handover" in tool_dispatch.test.ts.
|
|
341
|
+
if (this.#host.tenure() !== tenure) {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
244
344
|
// Drawn from the arguments rather than the result, so the live path and
|
|
245
345
|
// the replay path render the same thing from the same input.
|
|
246
346
|
if (tool.render !== undefined) {
|
|
@@ -252,12 +352,32 @@ export class ToolDispatch {
|
|
|
252
352
|
}
|
|
253
353
|
const content = JSON.stringify(result ?? null);
|
|
254
354
|
card.settle(TOOL_CALL_STATUS.DONE, content);
|
|
255
|
-
|
|
355
|
+
// The same check for a reset that is not a handover. The result is kept,
|
|
356
|
+
// as after a Stop, but the conversation on screen is not the one it was
|
|
357
|
+
// dispatched in, so the run is not shown waiting there.
|
|
358
|
+
//
|
|
359
|
+
// Held by "draws nothing into the new chat when a handler resolves after
|
|
360
|
+
// New chat" in tool_dispatch.test.ts, and for the other resets by the
|
|
361
|
+
// tests beside it.
|
|
362
|
+
if (this.#host.conversation() === conversation) {
|
|
363
|
+
this.#host.transcript.showPending();
|
|
364
|
+
}
|
|
256
365
|
return { content };
|
|
257
366
|
} catch (error) {
|
|
367
|
+
// The same handover check as on success, and here it also guards a
|
|
368
|
+
// write: the store is read live, and after a handover it is the next
|
|
369
|
+
// principal's, so the clear below removed their checkpoint rather than
|
|
370
|
+
// this call's -- which the handover's purge had already removed.
|
|
371
|
+
//
|
|
372
|
+
// Held by "leaves the next principal's checkpoint alone when a handler
|
|
373
|
+
// fails after a handover" in tool_dispatch.test.ts.
|
|
374
|
+
if (this.#host.tenure() !== tenure) {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
258
377
|
if (navigates) {
|
|
259
|
-
// The navigation never happened; drop the dangling checkpoint
|
|
260
|
-
|
|
378
|
+
// The navigation never happened; drop the dangling checkpoint, from
|
|
379
|
+
// the thread it was written to.
|
|
380
|
+
this.#host.conversationStore().saveCheckpoint(threadId, null);
|
|
261
381
|
}
|
|
262
382
|
// The handler's own message, verbatim, in two places at once: the card,
|
|
263
383
|
// which the user sees, and the tool result, which goes to the endpoint,
|
|
@@ -267,7 +387,14 @@ export class ToolDispatch {
|
|
|
267
387
|
// invisible from the host's side and is not one it can take back.
|
|
268
388
|
const message = error instanceof Error ? error.message : String(error);
|
|
269
389
|
card.settle(TOOL_CALL_STATUS.ERROR, message);
|
|
270
|
-
|
|
390
|
+
// As on success: the failure is still reported, and not drawn as a run
|
|
391
|
+
// waiting in a conversation the call is no longer in.
|
|
392
|
+
//
|
|
393
|
+
// Held by "draws nothing into the new chat when a handler fails after
|
|
394
|
+
// New chat" in tool_dispatch.test.ts.
|
|
395
|
+
if (this.#host.conversation() === conversation) {
|
|
396
|
+
this.#host.transcript.showPending();
|
|
397
|
+
}
|
|
271
398
|
return { content: `Error: ${message}`, error: message, outcome: TOOL_OUTCOME.FAILED };
|
|
272
399
|
}
|
|
273
400
|
}
|
|
@@ -479,6 +606,26 @@ export class ToolDispatch {
|
|
|
479
606
|
return isDestructive(tool.parameters) ? "destructive" : null;
|
|
480
607
|
}
|
|
481
608
|
|
|
609
|
+
/**
|
|
610
|
+
* Settle a call whose run ended while it waited, and give the loop nothing.
|
|
611
|
+
*
|
|
612
|
+
* The card is settled here because nothing else will: dispatch took it out
|
|
613
|
+
* of the end-of-run sweep before it waited, so left alone it read "running…"
|
|
614
|
+
* for good. It says the call did not finish, which is true -- nothing ran and
|
|
615
|
+
* nobody refused it.
|
|
616
|
+
*
|
|
617
|
+
* `null` rather than a result, because a stopped run has no next round to
|
|
618
|
+
* send one in. The loop moves on and finds the run stopped, and the call is
|
|
619
|
+
* left unanswered like every call after it in the round, so the client
|
|
620
|
+
* answers it as not finished on the next request -- in the same words as
|
|
621
|
+
* this card. After a handover the card is in a transcript already cleared,
|
|
622
|
+
* and settling it there is seen by no one.
|
|
623
|
+
*/
|
|
624
|
+
#abandoned(card: ToolCallCard): null {
|
|
625
|
+
card.settle(TOOL_CALL_STATUS.INTERRUPTED, this.#host.strings().callNotFinished);
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
|
|
482
629
|
/**
|
|
483
630
|
* Whether the page moved since the current round's context was built.
|
|
484
631
|
*
|
|
@@ -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,25 +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 {
|
|
110
|
+
stream = null;
|
|
111
|
+
}
|
|
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() ?? []) {
|
|
122
|
+
track.stop();
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (stream === null) {
|
|
100
127
|
this.#fail(this.#strings.transcriptionFailed);
|
|
101
128
|
return;
|
|
102
129
|
}
|
|
103
130
|
this.#stream = stream;
|
|
104
131
|
this.#chunks = [];
|
|
105
132
|
this.#hitCap = false;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
}
|
|
115
158
|
// Nothing else ends a recording: `MediaRecorder` runs until it is told to
|
|
116
159
|
// stop, so without this the only exits are a second click and `dispose()`.
|
|
117
160
|
this.#capTimer = setTimeout(() => {
|
|
@@ -136,10 +179,16 @@ export class VoiceInput {
|
|
|
136
179
|
}
|
|
137
180
|
|
|
138
181
|
/**
|
|
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
|
|
182
|
+
* Tear the control down, for a host element removed mid-recording or handed
|
|
183
|
+
* to the next principal. Stops any live `MediaRecorder`, releases the mic
|
|
184
|
+
* tracks so the browser's recording indicator clears, and suppresses the
|
|
185
|
+
* pending transcription — a disconnected control must not fire `onText` into
|
|
186
|
+
* a detached element.
|
|
187
|
+
*
|
|
188
|
+
* Disposal is final: nothing un-disposes an instance, and the host's next
|
|
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
|
|
191
|
+
* dropped whenever its answer arrives.
|
|
143
192
|
*/
|
|
144
193
|
dispose(): void {
|
|
145
194
|
this.#disposed = true;
|
|
@@ -160,6 +209,15 @@ export class VoiceInput {
|
|
|
160
209
|
const audio = new Blob(this.#chunks, { type: mimeType || "audio/webm" });
|
|
161
210
|
try {
|
|
162
211
|
const text = await this.#transcribe(audio);
|
|
212
|
+
if (this.#disposed) {
|
|
213
|
+
// Disposed while the clip was in flight. The check above ran before the
|
|
214
|
+
// await and cannot see this: on a change of user-key the host disposes
|
|
215
|
+
// and wires a fresh mic over the same composer, so delivering now
|
|
216
|
+
// would put the previous principal's words in the next one's box.
|
|
217
|
+
// The button is left as it was, too: it is no longer this control's
|
|
218
|
+
// to change.
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
163
221
|
this.#setState("idle");
|
|
164
222
|
if (this.#hitCap) {
|
|
165
223
|
// #setState has just reset the tooltip to the idle label, so this goes
|
|
@@ -188,6 +246,13 @@ export class VoiceInput {
|
|
|
188
246
|
}
|
|
189
247
|
|
|
190
248
|
#fail(message: string): void {
|
|
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.
|
|
253
|
+
if (this.#disposed) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
191
256
|
this.#releaseStream();
|
|
192
257
|
this.#recorder = null;
|
|
193
258
|
this.#setState("idle");
|