@artooi/ag-ui-web-component 0.39.0 → 0.40.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 +155 -1
- package/README.md +70 -12
- package/dist/ag-ui-web-component.bundle.js +168 -45
- package/dist/ag-ui-web-component.bundle.js.map +3 -3
- 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 +12 -0
- package/dist/core/agui_client.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/index.js +636 -231
- package/dist/index.js.map +2 -2
- 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 +29 -3
- 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 +1 -1
- package/src/core/ag_ui_chat.ts +217 -64
- package/src/core/agui_client.ts +26 -14
- package/src/core/client_seed.ts +14 -2
- 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 +125 -17
- 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
package/src/core/agui_client.ts
CHANGED
|
@@ -615,27 +615,39 @@ export class AgUiClient {
|
|
|
615
615
|
* unannotated save quietly threw the annotations away.
|
|
616
616
|
*/
|
|
617
617
|
#persist(): void {
|
|
618
|
+
this.#onPersist(this.annotatedMessages);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* The history in the form a save writes it: {@link messages}, with how each
|
|
623
|
+
* tool call ended annotated onto its result -- exactly what
|
|
624
|
+
* {@link AgUiClientConfig.onPersist} is handed.
|
|
625
|
+
*
|
|
626
|
+
* For writing the conversation somewhere other than a save. The element needs
|
|
627
|
+
* it when a checkpoint continuation adds to a conversation this client holds:
|
|
628
|
+
* the continuation's saves write the conversation ahead of the exchange, and
|
|
629
|
+
* the bare {@link messages} would drop the annotations, so a declined card
|
|
630
|
+
* turned green on the next reload.
|
|
631
|
+
*/
|
|
632
|
+
get annotatedMessages(): readonly Message[] {
|
|
618
633
|
const messages = this.#agent.messages;
|
|
619
634
|
if (this.#outcomes.size === 0) {
|
|
620
|
-
|
|
621
|
-
return;
|
|
635
|
+
return messages;
|
|
622
636
|
}
|
|
623
637
|
// A copy, and only of the messages that gain something. `agent.messages` is
|
|
624
638
|
// the list the next `runAgent` sends back to the server, so writing an extra
|
|
625
639
|
// field into it would put a client-side annotation on the wire; a store is
|
|
626
640
|
// allowed to hold more than the protocol does.
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
}),
|
|
638
|
-
);
|
|
641
|
+
return messages.map((message) => {
|
|
642
|
+
if (message.role !== "tool") {
|
|
643
|
+
return message;
|
|
644
|
+
}
|
|
645
|
+
const outcome = this.#outcomes.get(message.toolCallId);
|
|
646
|
+
// Cast at the AG-UI boundary, as the `attachments` augmentation on a
|
|
647
|
+
// user message already does: `Message` does not declare the field, and
|
|
648
|
+
// the default store round-trips it through `JSON.stringify` verbatim.
|
|
649
|
+
return outcome === undefined ? message : ({ ...message, outcome } as Message);
|
|
650
|
+
});
|
|
639
651
|
}
|
|
640
652
|
|
|
641
653
|
async #runLoop(): Promise<void> {
|
package/src/core/client_seed.ts
CHANGED
|
@@ -10,6 +10,18 @@ export interface ClientSeed {
|
|
|
10
10
|
readonly endpoint: string;
|
|
11
11
|
/** The history the agent starts from, and sends with its first run. */
|
|
12
12
|
readonly initialMessages: readonly Message[];
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The conversation this client's own history continues, which every save
|
|
15
|
+
* writes ahead of it.
|
|
16
|
+
*
|
|
17
|
+
* Empty for the conversation's own client, whose history is the whole
|
|
18
|
+
* conversation. A continuation's history is only the turn it adds and its
|
|
19
|
+
* answer -- the endpoint supplies the rest from its snapshot -- and a store
|
|
20
|
+
* keeps one list per thread, so a save of that history alone would replace
|
|
21
|
+
* the conversation with its last exchange, and not saving it lost the
|
|
22
|
+
* exchange on the next reload.
|
|
23
|
+
*/
|
|
24
|
+
readonly follows: readonly Message[];
|
|
25
|
+
/** Handed the whole conversation each time the client saves it, as written. */
|
|
26
|
+
readonly onSaved?: (conversation: readonly Message[]) => void;
|
|
15
27
|
}
|
|
@@ -264,13 +264,13 @@ export class ToolCatalog {
|
|
|
264
264
|
},
|
|
265
265
|
required: ["question"],
|
|
266
266
|
},
|
|
267
|
-
handler: (args) => this.#askUser(args),
|
|
267
|
+
handler: (args, callId) => this.#askUser(args, callId),
|
|
268
268
|
},
|
|
269
269
|
];
|
|
270
270
|
}
|
|
271
271
|
|
|
272
272
|
/** Render the `ask_user` question card and resolve with the user's answer. */
|
|
273
|
-
async #askUser(args: Record<string, unknown
|
|
273
|
+
async #askUser(args: Record<string, unknown>, callId: string | undefined): Promise<string> {
|
|
274
274
|
const question = typeof args["question"] === "string" ? args["question"] : "";
|
|
275
275
|
const request: QuestionRequest = { question };
|
|
276
276
|
const rawOptions = args["options"];
|
|
@@ -284,20 +284,71 @@ export class ToolCatalog {
|
|
|
284
284
|
// it with an empty answer (the run is then cancelled).
|
|
285
285
|
const signal = this.#host.decision.open();
|
|
286
286
|
this.#host.hidePending();
|
|
287
|
-
//
|
|
288
|
-
|
|
287
|
+
// The built-in inline card renders into the current answer group.
|
|
288
|
+
const builtIn = (): Promise<string> =>
|
|
289
|
+
requestQuestion(this.#host.ensureGroup(), request, {
|
|
290
|
+
signal,
|
|
291
|
+
strings: this.#host.strings(),
|
|
292
|
+
});
|
|
293
|
+
// A host-supplied renderer takes full control of the UI.
|
|
289
294
|
const renderer = this.#host.askUserRenderer();
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
295
|
+
let answer: string;
|
|
296
|
+
if (renderer === null) {
|
|
297
|
+
answer = await builtIn();
|
|
298
|
+
} else {
|
|
299
|
+
// Awaited here rather than inside a helper, so an answering renderer
|
|
300
|
+
// takes exactly as many turns to be heard as it always did.
|
|
301
|
+
try {
|
|
302
|
+
// Called on the element, as `this.askUserRenderer(...)` always was.
|
|
303
|
+
answer = await renderer.call(this.#host.element, request, { signal });
|
|
304
|
+
} catch (error) {
|
|
305
|
+
answer = await this.#afterRendererFailed(error, signal, callId, builtIn);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
298
308
|
this.#host.decision.close();
|
|
299
309
|
this.#host.updateEmptyState();
|
|
300
310
|
this.#host.follow();
|
|
301
311
|
return answer;
|
|
302
312
|
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Answer an `ask_user` call whose host renderer threw or rejected instead of
|
|
316
|
+
* answering: put the question to the built-in card.
|
|
317
|
+
*
|
|
318
|
+
* The same answer `approvalRenderer` gets for the same failure, and for the
|
|
319
|
+
* same reason: a renderer is presentation, not a policy. It decides how the
|
|
320
|
+
* question looks, never whether the agent's question is put to the user.
|
|
321
|
+
* Uncaught, the throw escaped the handler, so the call's card settled as an
|
|
322
|
+
* error quoting the host's message, that message went on to the agent as the
|
|
323
|
+
* tool result -- a detail of the host's page, never written for the model --
|
|
324
|
+
* and the pending decision was never closed, so the next Stop, in whatever
|
|
325
|
+
* round, aborted the signal of a wait that had already ended rather than
|
|
326
|
+
* finding nothing open. The built-in card still asks,
|
|
327
|
+
* and the run carries on as if no renderer had been set. Reported the way a
|
|
328
|
+
* failed `render` is, and for the same reason: survived is not the same as
|
|
329
|
+
* findable.
|
|
330
|
+
*
|
|
331
|
+
* Except when the wait was already abandoned. A renderer honouring its signal
|
|
332
|
+
* rejects once a Stop fires it, which is the signal working rather than the
|
|
333
|
+
* renderer failing, and a card drawn then would ask about a run the user just
|
|
334
|
+
* ended. So it resolves with the empty answer the built-in card resolves with
|
|
335
|
+
* on the same abort, and says nothing.
|
|
336
|
+
*/
|
|
337
|
+
#afterRendererFailed(
|
|
338
|
+
error: unknown,
|
|
339
|
+
signal: AbortSignal,
|
|
340
|
+
callId: string | undefined,
|
|
341
|
+
builtIn: () => Promise<string>,
|
|
342
|
+
): Promise<string> {
|
|
343
|
+
if (signal.aborted) {
|
|
344
|
+
return Promise.resolve("");
|
|
345
|
+
}
|
|
346
|
+
// Named by call rather than by question: the model can ask the same thing
|
|
347
|
+
// twice in one turn, and the id is the one thing that tells them apart.
|
|
348
|
+
console.warn(
|
|
349
|
+
`ag-ui-chat: askUserRenderer failed for tool call ${callId}, so the built-in question card asks instead`,
|
|
350
|
+
error,
|
|
351
|
+
);
|
|
352
|
+
return builtIn();
|
|
353
|
+
}
|
|
303
354
|
}
|
|
@@ -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. */
|
|
@@ -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()?.annotatedMessages ?? 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
|