@artooi/ag-ui-web-component 0.39.0 → 0.41.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 +208 -1
- package/README.md +116 -36
- package/dist/ag-ui-web-component.bundle.js +255 -101
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +3 -2
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts +7 -3
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +29 -8
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/attachment.d.ts +11 -4
- package/dist/core/attachment.d.ts.map +1 -1
- package/dist/core/client_seed.d.ts +14 -2
- package/dist/core/client_seed.d.ts.map +1 -1
- package/dist/core/tool_outcome.d.ts +4 -3
- package/dist/core/tool_outcome.d.ts.map +1 -1
- package/dist/core/utils.d.ts +19 -0
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/index.js +1078 -664
- package/dist/index.js.map +4 -4
- package/dist/tools/tool_catalog.d.ts.map +1 -1
- package/dist/ui/composer/composer_attachments.d.ts +6 -1
- package/dist/ui/composer/composer_attachments.d.ts.map +1 -1
- package/dist/ui/composer/composer_voice.d.ts +3 -0
- package/dist/ui/composer/composer_voice.d.ts.map +1 -1
- package/dist/ui/excerpts/transcript_quote_offer.d.ts +7 -2
- package/dist/ui/excerpts/transcript_quote_offer.d.ts.map +1 -1
- package/dist/ui/history/conversation_history.d.ts +30 -4
- package/dist/ui/history/conversation_history.d.ts.map +1 -1
- package/dist/ui/placement/launcher_drag.d.ts +6 -0
- package/dist/ui/placement/launcher_drag.d.ts.map +1 -1
- package/dist/ui/placement/panel_placement.d.ts +1 -1
- package/dist/ui/placement/panel_placement.d.ts.map +1 -1
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/transcript/transcript.d.ts +3 -2
- package/dist/ui/transcript/transcript.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +2 -0
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/constants.ts +3 -2
- package/src/core/ag_ui_chat.ts +217 -64
- package/src/core/agui_client.ts +155 -125
- package/src/core/attachment.ts +13 -5
- package/src/core/client_seed.ts +14 -2
- package/src/core/tool_outcome.ts +4 -3
- package/src/core/utils.ts +23 -0
- package/src/tools/tool_catalog.ts +63 -12
- package/src/ui/composer/composer_attachments.ts +64 -41
- package/src/ui/composer/composer_voice.ts +5 -0
- package/src/ui/excerpts/transcript_quote_offer.ts +26 -13
- package/src/ui/history/conversation_history.ts +149 -38
- package/src/ui/placement/launcher_drag.ts +104 -89
- package/src/ui/placement/panel_placement.ts +27 -3
- package/src/ui/styles.ts +137 -14
- package/src/ui/transcript/transcript.ts +10 -5
- package/src/ui/ui_strings.ts +3 -0
- package/src/version.ts +1 -1
|
@@ -57,8 +57,15 @@ export class ComposerAttachments {
|
|
|
57
57
|
* built-in multipart endpoint: reveal the 📎 button, wire the hidden file
|
|
58
58
|
* input + drag-and-drop, and mount the tray. With neither, the affordance
|
|
59
59
|
* stays hidden and the chat degrades to text-only.
|
|
60
|
+
*
|
|
61
|
+
* Called on every connect, so it starts by taking down the tray the last
|
|
62
|
+
* connection mounted: that one was disposed when the element left, and the
|
|
63
|
+
* attributes that decide whether there is a tray at all may have changed
|
|
64
|
+
* since. The shell outlives a connection, so its listeners go under `signal`.
|
|
60
65
|
*/
|
|
61
|
-
wire(): void {
|
|
66
|
+
wire(signal: AbortSignal): void {
|
|
67
|
+
this.#tray?.element.remove();
|
|
68
|
+
this.#tray = null;
|
|
62
69
|
const url = this.#host.element.getAttribute("data-attachments-url");
|
|
63
70
|
const upload = this.#host.uploadHandler() ?? this.#defaultUploadHandler(url);
|
|
64
71
|
if (upload === null) {
|
|
@@ -82,8 +89,8 @@ export class ComposerAttachments {
|
|
|
82
89
|
this.#host.slot.appendChild(this.#tray.element);
|
|
83
90
|
this.#host.fileInput.accept = accept;
|
|
84
91
|
this.#host.button.hidden = false;
|
|
85
|
-
this.#enableDragAndDrop();
|
|
86
|
-
this.#enablePaste(tray);
|
|
92
|
+
this.#enableDragAndDrop(signal);
|
|
93
|
+
this.#enablePaste(tray, signal);
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
/** The queueing behind `AgUiChat.attachFile`, whose doc is the contract. */
|
|
@@ -136,25 +143,37 @@ export class ComposerAttachments {
|
|
|
136
143
|
}
|
|
137
144
|
|
|
138
145
|
/** Accept files dropped anywhere on the chat shell into the tray. */
|
|
139
|
-
#enableDragAndDrop(): void {
|
|
146
|
+
#enableDragAndDrop(signal: AbortSignal): void {
|
|
140
147
|
const chat = this.#host.chat;
|
|
141
|
-
chat.addEventListener(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
148
|
+
chat.addEventListener(
|
|
149
|
+
"dragover",
|
|
150
|
+
(event) => {
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
chat.classList.add("chat--dragover");
|
|
153
|
+
},
|
|
154
|
+
{ signal },
|
|
155
|
+
);
|
|
156
|
+
chat.addEventListener(
|
|
157
|
+
"dragleave",
|
|
158
|
+
() => {
|
|
159
|
+
chat.classList.remove("chat--dragover");
|
|
160
|
+
},
|
|
161
|
+
{ signal },
|
|
162
|
+
);
|
|
163
|
+
chat.addEventListener(
|
|
164
|
+
"drop",
|
|
165
|
+
(event) => {
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
chat.classList.remove("chat--dragover");
|
|
168
|
+
const files = event.dataTransfer?.files;
|
|
169
|
+
if (files !== undefined) {
|
|
170
|
+
for (const file of Array.from(files)) {
|
|
171
|
+
this.#tray?.add(file);
|
|
172
|
+
}
|
|
155
173
|
}
|
|
156
|
-
}
|
|
157
|
-
|
|
174
|
+
},
|
|
175
|
+
{ signal },
|
|
176
|
+
);
|
|
158
177
|
}
|
|
159
178
|
|
|
160
179
|
/**
|
|
@@ -228,27 +247,31 @@ export class ComposerAttachments {
|
|
|
228
247
|
* clipboard, and swallowing the words someone meant to paste in order to
|
|
229
248
|
* attach a picture they did not is the worse of the two failures.
|
|
230
249
|
*/
|
|
231
|
-
#enablePaste(tray: AttachmentTray): void {
|
|
232
|
-
this.#host.chat.addEventListener(
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
250
|
+
#enablePaste(tray: AttachmentTray, signal: AbortSignal): void {
|
|
251
|
+
this.#host.chat.addEventListener(
|
|
252
|
+
"paste",
|
|
253
|
+
(event: ClipboardEvent) => {
|
|
254
|
+
// Nullish rather than a null check: the property is typed as nullable,
|
|
255
|
+
// and an engine that fires a plain Event for a paste leaves it absent
|
|
256
|
+
// instead, which is not the same value and is the same situation.
|
|
257
|
+
const clipboard = event.clipboardData ?? null;
|
|
258
|
+
if (clipboard === null) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const files = Array.from(clipboard.files);
|
|
262
|
+
if (files.length === 0) {
|
|
263
|
+
this.#pasteLongTextAsFile(event, clipboard, tray);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (clipboard.getData("text/plain") === "") {
|
|
267
|
+
event.preventDefault();
|
|
268
|
+
}
|
|
269
|
+
for (const file of files) {
|
|
270
|
+
this.#tray?.add(named(file));
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
{ signal },
|
|
274
|
+
);
|
|
252
275
|
}
|
|
253
276
|
|
|
254
277
|
/** Tell the host what the tray now holds — see {@link ATTACHMENT_EVENT}. */
|
|
@@ -44,8 +44,13 @@ export class ComposerVoice {
|
|
|
44
44
|
* built-in POST endpoint. The control records via `MediaRecorder` and drops
|
|
45
45
|
* the transcript into the composer; with neither configured the mic stays
|
|
46
46
|
* hidden and the chat is text-only.
|
|
47
|
+
*
|
|
48
|
+
* Called on every connect, so it starts by taking down the mic the last
|
|
49
|
+
* connection mounted, which was released when the element left.
|
|
47
50
|
*/
|
|
48
51
|
wire(): void {
|
|
52
|
+
this.#voice?.element.remove();
|
|
53
|
+
this.#voice = null;
|
|
49
54
|
const url = this.#host.element.getAttribute("data-transcribe-url");
|
|
50
55
|
const transcribe = this.#host.transcribeHandler() ?? this.#defaultTranscribeHandler(url);
|
|
51
56
|
if (transcribe === null) {
|
|
@@ -99,8 +99,13 @@ export class TranscriptQuoteOffer {
|
|
|
99
99
|
this.#pageQuote = null;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
/**
|
|
103
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Build the offer and listen for settled selections in the transcript.
|
|
104
|
+
*
|
|
105
|
+
* The button and the transcript outlive a connection, so the listeners go
|
|
106
|
+
* under `signal`, which the element aborts when it leaves the document.
|
|
107
|
+
*/
|
|
108
|
+
mount(signal: AbortSignal): void {
|
|
104
109
|
const button = this.button;
|
|
105
110
|
button.className = "quote-selection";
|
|
106
111
|
button.type = "button";
|
|
@@ -111,23 +116,31 @@ export class TranscriptQuoteOffer {
|
|
|
111
116
|
// selection first, and by the time a click lands there is nothing left to
|
|
112
117
|
// quote. Preventing the default keeps the selection alive long enough to
|
|
113
118
|
// read it.
|
|
114
|
-
button.addEventListener(
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
button.addEventListener(
|
|
120
|
+
"mousedown",
|
|
121
|
+
(event) => {
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
},
|
|
124
|
+
{ signal },
|
|
125
|
+
);
|
|
126
|
+
button.addEventListener(
|
|
127
|
+
"click",
|
|
128
|
+
() => {
|
|
129
|
+
this.#host.quote(this.#quoting);
|
|
130
|
+
window.getSelection()?.removeAllRanges();
|
|
131
|
+
this.#hide();
|
|
132
|
+
},
|
|
133
|
+
{ signal },
|
|
134
|
+
);
|
|
122
135
|
|
|
123
136
|
// A settled selection, by either input. `mouseup` rather than
|
|
124
137
|
// `selectionchange` so the offer does not chase the pointer mid-drag; the
|
|
125
138
|
// second half of the same gesture, `mousedown`, retires the previous offer
|
|
126
139
|
// before the new selection exists.
|
|
127
140
|
const messages = this.#host.messages;
|
|
128
|
-
messages.addEventListener("mouseup", (event) => this.#onSelectionSettled(event));
|
|
129
|
-
messages.addEventListener("keyup", () => this.#onSelectionSettled());
|
|
130
|
-
messages.addEventListener("mousedown", () => this.#hide());
|
|
141
|
+
messages.addEventListener("mouseup", (event) => this.#onSelectionSettled(event), { signal });
|
|
142
|
+
messages.addEventListener("keyup", () => this.#onSelectionSettled(), { signal });
|
|
143
|
+
messages.addEventListener("mousedown", () => this.#hide(), { signal });
|
|
131
144
|
}
|
|
132
145
|
|
|
133
146
|
/** Whether the transcript offers to quote what the user selects. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "@ag-ui/client";
|
|
2
|
-
import type
|
|
2
|
+
import { contentToText, type Message } from "@ag-ui/core";
|
|
3
3
|
import { MESSAGE_ROLE, TOOL_OUTCOME } from "../../constants.js";
|
|
4
4
|
import type { ActivityRegistry } from "../../core/activity_registry.js";
|
|
5
5
|
import type { AgUiClient } from "../../core/agui_client.js";
|
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
import type { MessageRole } from "../../core/message_role.js";
|
|
13
13
|
import { RunIndex } from "../../core/run_index.js";
|
|
14
14
|
import { toolStatusFromOutcome } from "../../core/tool_outcome.js";
|
|
15
|
-
import { answerUnansweredCalls, mintThread } from "../../core/utils.js";
|
|
15
|
+
import { answerUnansweredCalls, metadataOrTopLevel, mintThread } from "../../core/utils.js";
|
|
16
16
|
import type { ToolCatalog } from "../../tools/tool_catalog.js";
|
|
17
17
|
import type { AnswerActions } from "../transcript/answer_actions.js";
|
|
18
18
|
import { renderAttachmentChips } from "../transcript/attachment_chips.js";
|
|
@@ -61,6 +61,17 @@ export interface ConversationHistoryHost {
|
|
|
61
61
|
readonly appendMessage: (role: MessageRole, content: string) => HTMLDivElement;
|
|
62
62
|
/** Resize the composer to its content. */
|
|
63
63
|
readonly autoGrow: () => void;
|
|
64
|
+
/**
|
|
65
|
+
* A continuation has ended, whether or not it ever ran.
|
|
66
|
+
*
|
|
67
|
+
* The composer parks a turn typed while one is in flight, and learns a run
|
|
68
|
+
* has settled from its own events -- which never arrive for a continuation
|
|
69
|
+
* that failed before starting one. Without this, such a turn stayed parked
|
|
70
|
+
* with nothing left to release it.
|
|
71
|
+
*/
|
|
72
|
+
readonly continuationEnded: () => void;
|
|
73
|
+
/** The conversation's own client, or `null` until one is built. */
|
|
74
|
+
readonly client: () => AgUiClient | null;
|
|
64
75
|
/** The conversation's own client, built on first use. */
|
|
65
76
|
readonly ensureClient: () => AgUiClient;
|
|
66
77
|
/**
|
|
@@ -69,6 +80,14 @@ export interface ConversationHistoryHost {
|
|
|
69
80
|
* added to one can be missing from the other.
|
|
70
81
|
*/
|
|
71
82
|
readonly buildClient: (seed: ClientSeed) => AgUiClient;
|
|
83
|
+
/**
|
|
84
|
+
* Forget the conversation's own client, so the next one is built from
|
|
85
|
+
* {@link ConversationHistory.restored}. Nothing is cancelled: this is for a
|
|
86
|
+
* client that is not running.
|
|
87
|
+
*/
|
|
88
|
+
readonly releaseClient: () => void;
|
|
89
|
+
/** Whether an interaction is in flight, which the composer owns. */
|
|
90
|
+
readonly running: () => boolean;
|
|
72
91
|
/** Stop the in-flight run. */
|
|
73
92
|
readonly cancelRun: () => void;
|
|
74
93
|
/** Drop the in-memory run and transcript, leaving the thread untouched. */
|
|
@@ -97,10 +116,16 @@ export class ConversationHistory {
|
|
|
97
116
|
/** The active thread's id. Empty until the element connects. */
|
|
98
117
|
#threadId = "";
|
|
99
118
|
/**
|
|
100
|
-
* The
|
|
101
|
-
*
|
|
119
|
+
* The conversation as last written for the next client the element builds to
|
|
120
|
+
* start from: what the last restore replayed, or what a checkpoint
|
|
121
|
+
* continuation last saved after it. Emptied with the rest of the in-memory run.
|
|
102
122
|
*/
|
|
103
123
|
#restored: readonly Message[] = [];
|
|
124
|
+
/**
|
|
125
|
+
* Counts the conversations cleared away, so a continuation can tell whether
|
|
126
|
+
* the one it continued is still on screen when it saves.
|
|
127
|
+
*/
|
|
128
|
+
#cleared = 0;
|
|
104
129
|
// Bumped on every rehydrate; a replay whose generation is stale (a newer
|
|
105
130
|
// thread switch started while it awaited a slow store) drops its result.
|
|
106
131
|
#generation = 0;
|
|
@@ -121,11 +146,16 @@ export class ConversationHistory {
|
|
|
121
146
|
return this.#threadId;
|
|
122
147
|
}
|
|
123
148
|
|
|
124
|
-
/** The
|
|
149
|
+
/** The conversation as last written, for seeding the next client. */
|
|
125
150
|
get restored(): readonly Message[] {
|
|
126
151
|
return this.#restored;
|
|
127
152
|
}
|
|
128
153
|
|
|
154
|
+
/** The checkpoint continuation in flight, or `null` when none is running. */
|
|
155
|
+
get continuation(): AgUiClient | null {
|
|
156
|
+
return this.#continuation;
|
|
157
|
+
}
|
|
158
|
+
|
|
129
159
|
/** Point at the thread the store says is active. */
|
|
130
160
|
adoptActiveThread(): void {
|
|
131
161
|
this.#threadId = this.#host.conversationStore().threadId();
|
|
@@ -153,6 +183,9 @@ export class ConversationHistory {
|
|
|
153
183
|
/** Forget the messages the last restore seeded, with the rest of the run. */
|
|
154
184
|
forgetRestored(): void {
|
|
155
185
|
this.#restored = [];
|
|
186
|
+
// The element clears the conversation through here, so a continuation
|
|
187
|
+
// still saving afterwards learns the conversation is no longer this one.
|
|
188
|
+
this.#cleared += 1;
|
|
156
189
|
}
|
|
157
190
|
|
|
158
191
|
/** Delete the active thread if nothing was ever sent in it. */
|
|
@@ -258,13 +291,18 @@ export class ConversationHistory {
|
|
|
258
291
|
* Uses a short-lived agent pointed at the resume / fork endpoint and seeded
|
|
259
292
|
* with no history, because those endpoints supply the prior turns from the
|
|
260
293
|
* snapshot and re-sending them would duplicate. A separate agent makes that
|
|
261
|
-
* structural
|
|
262
|
-
* `run_id` the endpoints also require.
|
|
294
|
+
* structural and mints the fresh `run_id` the endpoints also require.
|
|
263
295
|
*
|
|
264
296
|
* Built by the same construction as the conversation's own client, so the
|
|
265
297
|
* continuation streams into the same transcript the user is looking at and
|
|
266
298
|
* runs under the same state, tools and bounds. It is the run in flight while
|
|
267
299
|
* it lasts: {@link stopContinuation} is how the element's Stop reaches it.
|
|
300
|
+
*
|
|
301
|
+
* What it adds joins the conversation. Its saves write the conversation on
|
|
302
|
+
* screen ahead of its exchange, and each one hands that whole list to the
|
|
303
|
+
* next client the element builds, so the next ordinary message is sent with
|
|
304
|
+
* the exchange it follows. The conversation's own client never held it, and
|
|
305
|
+
* was sending without it.
|
|
268
306
|
*/
|
|
269
307
|
async continueRun(runId: string, verb: CheckpointVerb): Promise<void> {
|
|
270
308
|
const index = this.runs();
|
|
@@ -275,6 +313,26 @@ export class ConversationHistory {
|
|
|
275
313
|
// documented empty panel, which has no rows either. Typed, not silent.
|
|
276
314
|
return;
|
|
277
315
|
}
|
|
316
|
+
if (this.#host.running() || this.#continuation !== null) {
|
|
317
|
+
// Refused rather than started beside it. The panel opens and its rows
|
|
318
|
+
// take a pick while a run streams, and a second run would draw its answer
|
|
319
|
+
// into the turn still arriving; a second continuation would also replace
|
|
320
|
+
// the one Stop reaches, leaving the first streaming where nothing could
|
|
321
|
+
// end it. Nor is the earlier run cancelled for it: a pick in a panel is not
|
|
322
|
+
// a Stop, and what is streaming may be the answer the user is waiting on.
|
|
323
|
+
//
|
|
324
|
+
// Both checks, because they see different moments. `running` is the
|
|
325
|
+
// composer's own state and spans every round of an interaction, but it
|
|
326
|
+
// is set when the run's first event arrives; a continuation is recorded
|
|
327
|
+
// here the moment it starts.
|
|
328
|
+
//
|
|
329
|
+
// Said at the composer, as an empty composer is below, because the row
|
|
330
|
+
// closed the panel before this ran. The typed turn stays where it is --
|
|
331
|
+
// it is what the user wants sent once the run is done -- and the caret
|
|
332
|
+
// goes back to it, where Escape stops the run.
|
|
333
|
+
this.#refuse(this.#host.strings().continueWhileRunning);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
278
336
|
const content = this.#host.input.value.trim();
|
|
279
337
|
if (content === "") {
|
|
280
338
|
// A continuation sends *only* the next turn -- the snapshot supplies
|
|
@@ -289,31 +347,81 @@ export class ConversationHistory {
|
|
|
289
347
|
// keystroke -- a transcript notice for a recoverable slip would outlive
|
|
290
348
|
// the slip. Focus follows for the same reason applying a skill moves it when
|
|
291
349
|
// a template is short of a field.
|
|
292
|
-
this.#
|
|
293
|
-
this.#host.hint.hidden = false;
|
|
294
|
-
this.#host.input.focus();
|
|
350
|
+
this.#refuse(this.#host.strings().continueNeedsTurn);
|
|
295
351
|
return;
|
|
296
352
|
}
|
|
297
353
|
this.#host.input.value = "";
|
|
298
354
|
this.#host.autoGrow();
|
|
355
|
+
const cleared = this.#cleared;
|
|
299
356
|
const client = this.#host.buildClient({
|
|
300
357
|
endpoint: verb === "resume" ? index.resumeUrl(runId) : index.forkUrl(runId),
|
|
301
358
|
// The seed the endpoints assume: nothing. The snapshot is the history.
|
|
302
359
|
initialMessages: [],
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
// client
|
|
306
|
-
|
|
360
|
+
// What its saves write ahead of the exchange: the conversation on screen,
|
|
361
|
+
// in the form the store holds it. From the conversation's own client when
|
|
362
|
+
// there is one, because that client keeps how each call ended beside its
|
|
363
|
+
// messages rather than on them; otherwise what the last restore or
|
|
364
|
+
// continuation wrote, which is already in that form.
|
|
365
|
+
//
|
|
366
|
+
// All of it, even where this forks an earlier run and the server's
|
|
367
|
+
// snapshot stops there: what is saved is what the screen shows.
|
|
368
|
+
follows: this.#host.client()?.messages ?? this.#restored,
|
|
369
|
+
onSaved: (conversation) => {
|
|
370
|
+
// A continuation stopped by New chat or a thread switch saves once its
|
|
371
|
+
// request closes, into its own thread; the conversation now on screen
|
|
372
|
+
// is not the one it continued.
|
|
373
|
+
if (cleared !== this.#cleared) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
// The conversation's own client holds the conversation without this
|
|
377
|
+
// exchange, and would send it that way. Released rather than patched:
|
|
378
|
+
// the next client is built from this list exactly as a reload builds
|
|
379
|
+
// one from the store, outcomes included.
|
|
380
|
+
//
|
|
381
|
+
// Nothing can run on the released client in between, because the
|
|
382
|
+
// composer refuses to start one while a continuation is in flight --
|
|
383
|
+
// it parks the turn instead. It said here that only a script in the
|
|
384
|
+
// same task could, which was wrong by a whole request: `running` does
|
|
385
|
+
// not turn on until the continuation's first event, and a person who
|
|
386
|
+
// typed in that window got a second run against the snapshot
|
|
387
|
+
// `follows` had already frozen, so whichever saved last dropped the
|
|
388
|
+
// other's turn.
|
|
389
|
+
this.#restored = conversation;
|
|
390
|
+
this.#host.releaseClient();
|
|
391
|
+
},
|
|
307
392
|
});
|
|
308
393
|
this.#continuation = client;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
394
|
+
try {
|
|
395
|
+
await client.send(content);
|
|
396
|
+
} finally {
|
|
397
|
+
// Only if it is still the one in flight: stopping forgets it at once, and
|
|
398
|
+
// a continuation started after that one is not this one to forget.
|
|
399
|
+
//
|
|
400
|
+
// In a finally because the send can fail before its run ever starts. The
|
|
401
|
+
// first save goes through the host's `conversationStore` synchronously
|
|
402
|
+
// inside it, and a store is the host's to replace: the built-in one
|
|
403
|
+
// swallows a write the browser refused, a server-backed one need not. A
|
|
404
|
+
// throw there left this pointing at a client that would never run, so
|
|
405
|
+
// every later pick was refused with no Stop to clear it -- the composer's
|
|
406
|
+
// button is Send until a run reports a start -- and `#liveClient` went on
|
|
407
|
+
// handing that dead client the shared state a host wrote.
|
|
408
|
+
if (this.#continuation === client) {
|
|
409
|
+
this.#continuation = null;
|
|
410
|
+
this.#host.continuationEnded();
|
|
411
|
+
}
|
|
314
412
|
}
|
|
315
413
|
}
|
|
316
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Say at the composer why a picked run did not continue, and put the caret
|
|
417
|
+
* there. The hint clears itself on the next keystroke.
|
|
418
|
+
*/
|
|
419
|
+
#refuse(reason: string): void {
|
|
420
|
+
this.#host.hint.textContent = reason;
|
|
421
|
+
this.#host.hint.hidden = false;
|
|
422
|
+
this.#host.input.focus();
|
|
423
|
+
}
|
|
424
|
+
|
|
317
425
|
/**
|
|
318
426
|
* Restore the conversation from the store on mount, then — if a navigating
|
|
319
427
|
* tool reloaded the page mid-run — resume the loop by supplying that tool's
|
|
@@ -379,16 +487,14 @@ export class ConversationHistory {
|
|
|
379
487
|
const restored = answerUnansweredCalls(
|
|
380
488
|
messages,
|
|
381
489
|
// The shape the store holds for a call the client answered the same way:
|
|
382
|
-
// its tool message, with the outcome
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
outcome: TOOL_OUTCOME.INTERRUPTED,
|
|
391
|
-
}) as Message,
|
|
490
|
+
// its tool message, with the outcome in its metadata.
|
|
491
|
+
(toolCallId) => ({
|
|
492
|
+
id: randomUUID(),
|
|
493
|
+
role: "tool",
|
|
494
|
+
content: unfinished,
|
|
495
|
+
toolCallId,
|
|
496
|
+
metadata: { outcome: TOOL_OUTCOME.INTERRUPTED },
|
|
497
|
+
}),
|
|
392
498
|
new Set(checkpoint === null ? [] : [checkpoint.toolCallId]),
|
|
393
499
|
);
|
|
394
500
|
this.#restored = restored;
|
|
@@ -503,16 +609,21 @@ export class ConversationHistory {
|
|
|
503
609
|
if (message.role === "tool") {
|
|
504
610
|
const card = this.#host.transcript.card(message.toolCallId);
|
|
505
611
|
if (card !== undefined) {
|
|
506
|
-
// The outcome
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
612
|
+
// The outcome in the message's metadata -- folded there from a server's
|
|
613
|
+
// result by `@ag-ui/client`, or written there by `AgUiClient` for a
|
|
614
|
+
// result it made itself -- read back through the same mapping the live
|
|
615
|
+
// path uses, so a card that said "declined" before the reload still
|
|
616
|
+
// says it after. History stored by an earlier release has it at the
|
|
617
|
+
// top level instead, which is read when the metadata has none. Narrowed
|
|
618
|
+
// off `unknown` rather than trusted, like every other field read out of
|
|
619
|
+
// the store: metadata is open by key, a host store may not round-trip
|
|
620
|
+
// it, and history written before outcomes existed has none. All three
|
|
621
|
+
// land on DONE, which is what this line did unconditionally.
|
|
513
622
|
card.settle(
|
|
514
|
-
toolStatusFromOutcome((message
|
|
515
|
-
|
|
623
|
+
toolStatusFromOutcome(metadataOrTopLevel(message, "outcome")),
|
|
624
|
+
// Flattened exactly as the live path flattens it, so a reload shows
|
|
625
|
+
// the card the user watched settle.
|
|
626
|
+
contentToText(message.content),
|
|
516
627
|
);
|
|
517
628
|
}
|
|
518
629
|
}
|