@artooi/ag-ui-web-component 0.38.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 +515 -1
- package/README.md +232 -56
- package/dist/ag-ui-web-component.bundle.js +370 -139
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +17 -14
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/activity_registry.d.ts +46 -0
- package/dist/core/activity_registry.d.ts.map +1 -0
- package/dist/core/ag_ui_chat.d.ts +23 -10
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +28 -0
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/client_seed.d.ts +27 -0
- package/dist/core/client_seed.d.ts.map +1 -0
- package/dist/core/read_max_tool_rounds.d.ts +9 -0
- package/dist/core/read_max_tool_rounds.d.ts.map +1 -0
- package/dist/core/run_handlers.d.ts +80 -0
- package/dist/core/run_handlers.d.ts.map +1 -0
- package/dist/core/storage_scope.d.ts +110 -0
- package/dist/core/storage_scope.d.ts.map +1 -0
- package/dist/core/tool_outcome.d.ts +2 -3
- package/dist/core/tool_outcome.d.ts.map +1 -1
- package/dist/core/utils.d.ts +46 -0
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/index.js +16268 -14747
- package/dist/index.js.map +4 -4
- package/dist/skills/skill_catalog.d.ts +67 -0
- package/dist/skills/skill_catalog.d.ts.map +1 -0
- package/dist/skills/skill_name_from.d.ts +12 -0
- package/dist/skills/skill_name_from.d.ts.map +1 -0
- package/dist/tools/tool_catalog.d.ts +79 -0
- package/dist/tools/tool_catalog.d.ts.map +1 -0
- package/dist/tools/tool_dispatch.d.ts +104 -0
- package/dist/tools/tool_dispatch.d.ts.map +1 -0
- package/dist/ui/composer/attachment_tray.d.ts.map +1 -1
- package/dist/ui/composer/auto_grow.d.ts +10 -0
- package/dist/ui/composer/auto_grow.d.ts.map +1 -0
- package/dist/ui/composer/composer_attachments.d.ts +57 -0
- package/dist/ui/composer/composer_attachments.d.ts.map +1 -0
- package/dist/ui/composer/composer_voice.d.ts +48 -0
- package/dist/ui/composer/composer_voice.d.ts.map +1 -0
- package/dist/ui/composer/voice_input.d.ts.map +1 -1
- package/dist/ui/excerpts/transcript_quote_offer.d.ts +57 -0
- package/dist/ui/excerpts/transcript_quote_offer.d.ts.map +1 -0
- package/dist/ui/fill_ui_string.d.ts +32 -0
- package/dist/ui/fill_ui_string.d.ts.map +1 -0
- package/dist/ui/history/conversation_history.d.ts +203 -0
- package/dist/ui/history/conversation_history.d.ts.map +1 -0
- package/dist/ui/history/relative_time.d.ts.map +1 -1
- package/dist/ui/history/thread_drawer.d.ts +0 -14
- package/dist/ui/history/thread_drawer.d.ts.map +1 -1
- package/dist/ui/interrupts/confirmation_card.d.ts.map +1 -1
- package/dist/ui/interrupts/pending_decision.d.ts +23 -0
- package/dist/ui/interrupts/pending_decision.d.ts.map +1 -0
- package/dist/ui/placement/is_collapsible_placement.d.ts +18 -0
- package/dist/ui/placement/is_collapsible_placement.d.ts.map +1 -0
- package/dist/ui/placement/is_draggable_placement.d.ts +9 -0
- package/dist/ui/placement/is_draggable_placement.d.ts.map +1 -0
- 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 +119 -0
- package/dist/ui/placement/panel_placement.d.ts.map +1 -0
- package/dist/ui/progress/run_announcer.d.ts +41 -0
- package/dist/ui/progress/run_announcer.d.ts.map +1 -0
- package/dist/ui/progress/subagent_progress.d.ts +58 -0
- package/dist/ui/progress/subagent_progress.d.ts.map +1 -0
- package/dist/ui/progress/tool_call_card.d.ts.map +1 -1
- package/dist/ui/shell/adopt_styles.d.ts +23 -0
- package/dist/ui/shell/adopt_styles.d.ts.map +1 -0
- package/dist/ui/shell/glyph_slot.d.ts +7 -0
- package/dist/ui/shell/glyph_slot.d.ts.map +1 -0
- package/dist/ui/shell/header_button.d.ts +10 -0
- package/dist/ui/shell/header_button.d.ts.map +1 -0
- package/dist/ui/shell/icon_element.d.ts +12 -0
- package/dist/ui/shell/icon_element.d.ts.map +1 -0
- package/dist/ui/shell/is_unread_badge_enabled.d.ts +10 -0
- package/dist/ui/shell/is_unread_badge_enabled.d.ts.map +1 -0
- package/dist/ui/shell/read_launcher_icon_url.d.ts +8 -0
- package/dist/ui/shell/read_launcher_icon_url.d.ts.map +1 -0
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/transcript/answer_actions.d.ts +39 -0
- package/dist/ui/transcript/answer_actions.d.ts.map +1 -0
- package/dist/ui/transcript/answer_stream.d.ts +56 -0
- package/dist/ui/transcript/answer_stream.d.ts.map +1 -0
- package/dist/ui/transcript/render_or_warn.d.ts +16 -0
- package/dist/ui/transcript/render_or_warn.d.ts.map +1 -0
- package/dist/ui/transcript/starter_chips.d.ts +21 -0
- package/dist/ui/transcript/starter_chips.d.ts.map +1 -0
- package/dist/ui/transcript/transcript.d.ts +171 -0
- package/dist/ui/transcript/transcript.d.ts.map +1 -0
- package/dist/ui/ui_strings.d.ts +29 -1
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +17 -15
- package/src/core/activity_registry.ts +133 -0
- package/src/core/ag_ui_chat.ts +945 -4419
- package/src/core/agui_client.ts +172 -16
- package/src/core/client_seed.ts +27 -0
- package/src/core/read_max_tool_rounds.ts +13 -0
- package/src/core/remote_conversation_store.ts +1 -1
- package/src/core/run_handlers.ts +501 -0
- package/src/core/storage_scope.ts +279 -0
- package/src/core/tool_outcome.ts +5 -3
- package/src/core/utils.ts +109 -0
- package/src/skills/skill_catalog.ts +166 -0
- package/src/skills/skill_name_from.ts +19 -0
- package/src/tools/tool_catalog.ts +354 -0
- package/src/tools/tool_dispatch.ts +521 -0
- package/src/ui/composer/attachment_tray.ts +6 -5
- package/src/ui/composer/auto_grow.ts +12 -0
- package/src/ui/composer/composer_attachments.ts +324 -0
- package/src/ui/composer/composer_voice.ts +93 -0
- package/src/ui/composer/voice_input.ts +4 -4
- package/src/ui/excerpts/transcript_quote_offer.ts +198 -0
- package/src/ui/fill_ui_string.ts +41 -0
- package/src/ui/history/conversation_history.ts +699 -0
- package/src/ui/history/relative_time.ts +5 -4
- package/src/ui/history/thread_drawer.ts +3 -50
- package/src/ui/interrupts/confirmation_card.ts +4 -2
- package/src/ui/interrupts/pending_decision.ts +34 -0
- package/src/ui/placement/is_collapsible_placement.ts +19 -0
- package/src/ui/placement/is_draggable_placement.ts +23 -0
- package/src/ui/placement/launcher_drag.ts +104 -89
- package/src/ui/placement/panel_placement.ts +1130 -0
- package/src/ui/progress/run_announcer.ts +67 -0
- package/src/ui/progress/subagent_progress.ts +201 -0
- package/src/ui/progress/tool_call_card.ts +2 -0
- package/src/ui/shell/adopt_styles.ts +28 -0
- package/src/ui/shell/glyph_slot.ts +12 -0
- package/src/ui/shell/header_button.ts +21 -0
- package/src/ui/shell/icon_element.ts +33 -0
- package/src/ui/shell/is_unread_badge_enabled.ts +11 -0
- package/src/ui/shell/read_launcher_icon_url.ts +9 -0
- package/src/ui/styles.ts +301 -70
- package/src/ui/transcript/answer_actions.ts +139 -0
- package/src/ui/transcript/answer_stream.ts +123 -0
- package/src/ui/transcript/render_or_warn.ts +22 -0
- package/src/ui/transcript/starter_chips.ts +42 -0
- package/src/ui/transcript/transcript.ts +429 -0
- package/src/ui/ui_strings.ts +38 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { randomUUID } from "@ag-ui/client";
|
|
2
|
+
import {
|
|
3
|
+
type ClientConversationStore,
|
|
4
|
+
SessionStorageStore,
|
|
5
|
+
writeStoredItem,
|
|
6
|
+
} from "./conversation_store.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Storage namespaces already spoken for in this document.
|
|
10
|
+
*
|
|
11
|
+
* Per document rather than per origin, and released on disconnect, because the
|
|
12
|
+
* question it answers is "is another element on this page using these keys right
|
|
13
|
+
* now" — not "has anything ever used them". A registry that never released would
|
|
14
|
+
* turn every remount, and every framework re-render that moves the node, into a
|
|
15
|
+
* false collision that costs the element its own conversation.
|
|
16
|
+
*
|
|
17
|
+
* Module-level on purpose, and the one piece of shared mutable state the
|
|
18
|
+
* repository's `CLAUDE.md` exempts from its rule against it: the question is
|
|
19
|
+
* about the *other* live elements, which no instance field can answer. See the
|
|
20
|
+
* three conditions recorded there before adding anything beside it.
|
|
21
|
+
*/
|
|
22
|
+
const CLAIMED_NAMESPACES = new Set<string>();
|
|
23
|
+
|
|
24
|
+
/** What the storage scope needs from the element that owns it. */
|
|
25
|
+
export interface StorageScopeHost {
|
|
26
|
+
/** The element's `id`, the namespace it prefers. */
|
|
27
|
+
readonly id: () => string;
|
|
28
|
+
/** The element's `endpoint`, the namespace it falls back to. */
|
|
29
|
+
readonly endpoint: () => string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Which storage keys are this element's: the namespace it claims in the
|
|
34
|
+
* document, the key a layout preference is stored under, and the built-in
|
|
35
|
+
* conversation store scoped to it.
|
|
36
|
+
*
|
|
37
|
+
* Owned one-to-one by an `<ag-ui-chat>`. The one thing it shares is the record
|
|
38
|
+
* of claimed namespaces above, which is what keeps two elements apart.
|
|
39
|
+
*/
|
|
40
|
+
export class StorageScope {
|
|
41
|
+
readonly #host: StorageScopeHost;
|
|
42
|
+
// Per-instance suffix for the origin-scoped storage keys (collapsed / theme /
|
|
43
|
+
// size), so two instances on one origin don't clobber each other. Empty ⇒ the
|
|
44
|
+
// pre-namespacing global keys (back-compat). Resolved on connect; the
|
|
45
|
+
// conversation adds `user-key` on top of it, see conversationNamespace.
|
|
46
|
+
#storageNs = "";
|
|
47
|
+
// The entry this element put in CLAIMED_NAMESPACES, to take back out on
|
|
48
|
+
// disconnect. `null` when it claimed nothing (no id, no endpoint, or it lost
|
|
49
|
+
// the claim to an element that mounted first).
|
|
50
|
+
#claimedNs: string | null = null;
|
|
51
|
+
// The fallback namespace minted when the preferred one was already claimed,
|
|
52
|
+
// with the preferred value it was minted for — so the element keeps it across
|
|
53
|
+
// remounts, but re-resolves if the host answers the warning with an `id`.
|
|
54
|
+
#generatedNs = "";
|
|
55
|
+
#generatedFor = "";
|
|
56
|
+
// The `sessionStorage`-backed store, which the element may therefore re-scope
|
|
57
|
+
// on a principal change. `null` when the host injected a store of its own
|
|
58
|
+
// kind, whose keying the element does not know and must not guess at.
|
|
59
|
+
#builtinStore: SessionStorageStore | null = null;
|
|
60
|
+
|
|
61
|
+
constructor(host: StorageScopeHost) {
|
|
62
|
+
this.#host = host;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the per-instance storage namespace, before any key is read or
|
|
67
|
+
* written, so this instance doesn't share collapsed/theme/thread state with
|
|
68
|
+
* another on the same origin.
|
|
69
|
+
*/
|
|
70
|
+
claim(): void {
|
|
71
|
+
this.#storageNs = this.#claimNamespace();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Give the namespace back. A disconnect is not necessarily a farewell — a
|
|
76
|
+
* DOM move and a framework re-render both look like one — and an element
|
|
77
|
+
* that could not reclaim its own namespace on the way back in would lose its
|
|
78
|
+
* conversation to a false collision.
|
|
79
|
+
*/
|
|
80
|
+
release(): void {
|
|
81
|
+
if (this.#claimedNs !== null) {
|
|
82
|
+
CLAIMED_NAMESPACES.delete(this.#claimedNs);
|
|
83
|
+
this.#claimedNs = null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The conversation store's namespace: this element's, scoped to the principal
|
|
89
|
+
* `key` names.
|
|
90
|
+
*
|
|
91
|
+
* Only the conversation is principal-scoped. The panel's own collapsed / size
|
|
92
|
+
* / theme preferences stay on the element's own namespace, because they are
|
|
93
|
+
* this element's UI state rather than anyone's data — they carry no word of
|
|
94
|
+
* what was said — and because they are read once while connecting, so
|
|
95
|
+
* re-scoping them under a live element would rearrange the panel around a
|
|
96
|
+
* user who had only just signed in.
|
|
97
|
+
*/
|
|
98
|
+
conversationNamespace(key: string): string {
|
|
99
|
+
return key === "" ? this.#storageNs : `${this.#storageNs}#${key}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Namespace the built-in default store, and hand back the store to use. A
|
|
104
|
+
* host-injected store is used verbatim; the built-in one is remembered
|
|
105
|
+
* either way, because it is the element's own store and a later `user-key`
|
|
106
|
+
* change may move it to another namespace.
|
|
107
|
+
*/
|
|
108
|
+
scopeStore(store: ClientConversationStore, key: string): ClientConversationStore {
|
|
109
|
+
if (!(store instanceof SessionStorageStore)) {
|
|
110
|
+
return store;
|
|
111
|
+
}
|
|
112
|
+
const namespace = this.conversationNamespace(key);
|
|
113
|
+
this.#builtinStore = namespace === "" ? store : new SessionStorageStore(namespace);
|
|
114
|
+
return this.#builtinStore;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Rebuild the built-in store under `namespace`, or `null` for a store of the
|
|
119
|
+
* host's own kind, which holds its data somewhere the element cannot see and
|
|
120
|
+
* has to scope itself.
|
|
121
|
+
*/
|
|
122
|
+
rescopeStore(namespace: string): SessionStorageStore | null {
|
|
123
|
+
if (this.#builtinStore === null) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
this.#builtinStore = new SessionStorageStore(namespace);
|
|
127
|
+
return this.#builtinStore;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** This instance's namespaced form of an origin-scoped storage key. */
|
|
131
|
+
key(base: string): string {
|
|
132
|
+
return this.#storageNs === "" ? base : `${base}:${this.#storageNs}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Read a namespaced origin-scoped value, falling back once to the legacy
|
|
137
|
+
* pre-namespacing global key (left in place) so an existing collapsed/theme
|
|
138
|
+
* preference survives the upgrade.
|
|
139
|
+
*/
|
|
140
|
+
readScopedItem(base: string): string | null {
|
|
141
|
+
const scoped = sessionStorage.getItem(this.key(base));
|
|
142
|
+
if (scoped !== null || this.#storageNs === "") {
|
|
143
|
+
return scoped;
|
|
144
|
+
}
|
|
145
|
+
return sessionStorage.getItem(base);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Read a layout preference: where the widget sits, how big it is, which
|
|
150
|
+
* theme it wears.
|
|
151
|
+
*
|
|
152
|
+
* These live in `localStorage` rather than beside the transcript, because a
|
|
153
|
+
* layout preference is not a conversation. The transcript is deliberately
|
|
154
|
+
* per-tab -- two tabs are two conversations, and closing the tab ends it --
|
|
155
|
+
* and everything else inherited that scoping without earning it. A user who
|
|
156
|
+
* dragged the panel clear of their own UI did it again in the next tab, and
|
|
157
|
+
* again after every restart.
|
|
158
|
+
*
|
|
159
|
+
* Whether the widget is *currently open* stays per-tab with the transcript.
|
|
160
|
+
* It is a statement about this tab rather than a preference: carrying it
|
|
161
|
+
* across would pop the panel open on every new tab because it was opened
|
|
162
|
+
* once, somewhere else.
|
|
163
|
+
*
|
|
164
|
+
* Falls back to the session value it used to be written to, so an existing
|
|
165
|
+
* position survives the upgrade rather than resetting once.
|
|
166
|
+
*/
|
|
167
|
+
readPreference(base: string): string | null {
|
|
168
|
+
try {
|
|
169
|
+
const stored = localStorage.getItem(this.key(base));
|
|
170
|
+
if (stored !== null) {
|
|
171
|
+
return stored;
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
// Fall through to the per-tab copy below.
|
|
175
|
+
}
|
|
176
|
+
return this.readScopedItem(base);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Persist a layout preference as durably as this browser allows: to
|
|
181
|
+
* `localStorage` so it outlives the tab, and to the per-tab store as well.
|
|
182
|
+
*
|
|
183
|
+
* The second write is not redundancy for its own sake. A privacy mode can
|
|
184
|
+
* deny `localStorage` while allowing `sessionStorage`, and losing the
|
|
185
|
+
* durable copy should degrade to the per-tab behaviour this replaced rather
|
|
186
|
+
* than to no persistence at all. The read above prefers the durable copy, so
|
|
187
|
+
* a tab that has both cannot be shadowed by its own stale one.
|
|
188
|
+
*
|
|
189
|
+
* Neither write is worth an exception. Losing where the panel sat is not
|
|
190
|
+
* worth a warning either -- unlike the transcript, which says so once,
|
|
191
|
+
* because losing that loses the conversation on the next reload.
|
|
192
|
+
*/
|
|
193
|
+
writePreference(base: string, value: string): void {
|
|
194
|
+
const key = this.key(base);
|
|
195
|
+
try {
|
|
196
|
+
localStorage.setItem(key, value);
|
|
197
|
+
} catch {
|
|
198
|
+
// Quota, or a store that denies writes.
|
|
199
|
+
}
|
|
200
|
+
writeStoredItem(key, value);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Drop a layout preference from both stores.
|
|
205
|
+
*
|
|
206
|
+
* The mirror of {@link writePreference}, and it has to clear both for the
|
|
207
|
+
* same reason that writes both: leaving either copy behind means the value
|
|
208
|
+
* comes back on the next read.
|
|
209
|
+
*/
|
|
210
|
+
clearPreference(base: string): void {
|
|
211
|
+
const key = this.key(base);
|
|
212
|
+
try {
|
|
213
|
+
localStorage.removeItem(key);
|
|
214
|
+
} catch {
|
|
215
|
+
// A store that denies access; the per-tab copy below still goes.
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
sessionStorage.removeItem(key);
|
|
219
|
+
} catch {
|
|
220
|
+
// Nothing left to do: the value was never persisted in the first place.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Claim this element's storage namespace: its `id`, else its `endpoint`.
|
|
226
|
+
*
|
|
227
|
+
* The endpoint fallback exists so a lone widget restores its conversation
|
|
228
|
+
* across reloads with nothing asked of the page author. It stops working the
|
|
229
|
+
* moment there are two of them — a docked support panel and an inline page
|
|
230
|
+
* assistant against one agent mount, neither carrying an `id`, which nothing
|
|
231
|
+
* requires — because both resolve to the same string and then share a thread
|
|
232
|
+
* pointer, a drawer index and every message key. Whichever mounts second
|
|
233
|
+
* adopts the first's active thread and rehydrates its transcript into its own
|
|
234
|
+
* panel: one conversation's content inside another, on the same page.
|
|
235
|
+
*
|
|
236
|
+
* So the namespace is claimed by the first element to mount under it, and a
|
|
237
|
+
* second is given one of its own plus a warning naming the fix. The first
|
|
238
|
+
* element keeps the endpoint namespace, which is what leaves the ordinary
|
|
239
|
+
* single-element case exactly as it was.
|
|
240
|
+
*
|
|
241
|
+
* The generated namespace is random rather than derived from mount order.
|
|
242
|
+
* That costs the second element its history across reloads — the warning says
|
|
243
|
+
* so, and an `id` fixes it — which is the honest trade against an order-based
|
|
244
|
+
* name that would silently hand a stored conversation to whichever element
|
|
245
|
+
* happened to mount second on the next load.
|
|
246
|
+
*/
|
|
247
|
+
#claimNamespace(): string {
|
|
248
|
+
const id = this.#host.id();
|
|
249
|
+
const preferred = id !== "" ? id : this.#host.endpoint();
|
|
250
|
+
// Nothing to key on. The pre-namespacing global keys, as before: an element
|
|
251
|
+
// with neither an id nor an endpoint cannot send anything, so what it would
|
|
252
|
+
// be claiming is an empty conversation.
|
|
253
|
+
if (preferred === "") {
|
|
254
|
+
return "";
|
|
255
|
+
}
|
|
256
|
+
// Already lost this claim once. Keep the fallback rather than drifting back
|
|
257
|
+
// onto a namespace the other element may since have released, which would
|
|
258
|
+
// swap this panel's conversation for that one's.
|
|
259
|
+
if (this.#generatedFor === preferred) {
|
|
260
|
+
return this.#generatedNs;
|
|
261
|
+
}
|
|
262
|
+
if (!CLAIMED_NAMESPACES.has(preferred)) {
|
|
263
|
+
CLAIMED_NAMESPACES.add(preferred);
|
|
264
|
+
this.#claimedNs = preferred;
|
|
265
|
+
return preferred;
|
|
266
|
+
}
|
|
267
|
+
this.#generatedFor = preferred;
|
|
268
|
+
this.#generatedNs = `${preferred}~${randomUUID()}`;
|
|
269
|
+
console.warn(
|
|
270
|
+
`<ag-ui-chat>: another element on this page already stores its ` +
|
|
271
|
+
`conversation under "${preferred}", so this one has been given a ` +
|
|
272
|
+
"throwaway namespace of its own — the two would otherwise share a " +
|
|
273
|
+
"thread pointer, a history drawer and every message. Give each " +
|
|
274
|
+
"<ag-ui-chat> its own id to keep them apart and let this one restore " +
|
|
275
|
+
"its conversation across reloads.",
|
|
276
|
+
);
|
|
277
|
+
return this.#generatedNs;
|
|
278
|
+
}
|
|
279
|
+
}
|
package/src/core/tool_outcome.ts
CHANGED
|
@@ -21,9 +21,8 @@ export type ToolOutcome = (typeof TOOL_OUTCOME)[keyof typeof TOOL_OUTCOME];
|
|
|
21
21
|
* expected to be successes, but because the alternative is worse in the
|
|
22
22
|
* direction that matters: a card claiming a call failed when it did not is a
|
|
23
23
|
* lie the user acts on, while a card claiming success has at least the result
|
|
24
|
-
* text under it for them to read.
|
|
25
|
-
*
|
|
26
|
-
* may add more. Forward compatibility is the point of the open field.
|
|
24
|
+
* text under it for them to read. A future release may add words, and forward
|
|
25
|
+
* compatibility is the point of the open field.
|
|
27
26
|
*/
|
|
28
27
|
export function toolStatusFromOutcome(outcome: unknown): SettledStatus {
|
|
29
28
|
if (outcome === TOOL_OUTCOME.FAILED) {
|
|
@@ -32,5 +31,8 @@ export function toolStatusFromOutcome(outcome: unknown): SettledStatus {
|
|
|
32
31
|
if (outcome === TOOL_OUTCOME.DENIED) {
|
|
33
32
|
return TOOL_CALL_STATUS.DECLINED;
|
|
34
33
|
}
|
|
34
|
+
if (outcome === TOOL_OUTCOME.INTERRUPTED) {
|
|
35
|
+
return TOOL_CALL_STATUS.INTERRUPTED;
|
|
36
|
+
}
|
|
35
37
|
return TOOL_CALL_STATUS.DONE;
|
|
36
38
|
}
|
package/src/core/utils.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Non-exported-from-index helpers shared by the core modules.
|
|
2
2
|
|
|
3
3
|
import { randomUUID } from "@ag-ui/client";
|
|
4
|
+
import type { Message } from "@ag-ui/core";
|
|
4
5
|
import type { ClientConversationStore } from "./conversation_store.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -96,3 +97,111 @@ export function warnOnCrossOriginCredentials(
|
|
|
96
97
|
"silence this notice. Reported once per origin.",
|
|
97
98
|
);
|
|
98
99
|
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The tokens of a comma-separated attribute value, trimmed, with empty ones
|
|
103
|
+
* dropped -- so `"scroll, drag,"` is `["scroll", "drag"]`.
|
|
104
|
+
*
|
|
105
|
+
* Shared by every attribute that names a set of opt-ins, so each one reads a
|
|
106
|
+
* stray space or a trailing comma the same way.
|
|
107
|
+
*/
|
|
108
|
+
export function commaTokens(value: string): string[] {
|
|
109
|
+
return value
|
|
110
|
+
.split(",")
|
|
111
|
+
.map((token) => token.trim())
|
|
112
|
+
.filter((token) => token !== "");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `messages` with a result for every tool call that has none, each placed at the
|
|
117
|
+
* end of the round that made the call.
|
|
118
|
+
*
|
|
119
|
+
* **Why every call.** Several model providers reject a request carrying a tool
|
|
120
|
+
* call with no result, and a conversation that sends one cannot continue. A run
|
|
121
|
+
* leaves calls unanswered in more ways than any one path can list: Stop while
|
|
122
|
+
* the stream is still arriving, a round ending on `RUN_ERROR`, a name no tool
|
|
123
|
+
* here owns, a server that never streamed a result, a page reloaded mid-run.
|
|
124
|
+
* Answering whatever is unanswered, rather than each of those where it happens,
|
|
125
|
+
* is what keeps a path nobody listed from sending one.
|
|
126
|
+
*
|
|
127
|
+
* **Where each result goes.** At the end of its round: the assistant turns
|
|
128
|
+
* carrying calls, then the results that follow them. That is the shape the run
|
|
129
|
+
* loop writes for a round it executed, and a provider reads a result only in the
|
|
130
|
+
* turn straight after the calls; appended at the end of history, it would sit
|
|
131
|
+
* behind a user turn or a reply the conversation has moved on to. A call with a
|
|
132
|
+
* result after a *later* turn is left alone, since it is answered.
|
|
133
|
+
*
|
|
134
|
+
* `answer` builds the one message for a call id, so the caller decides what is
|
|
135
|
+
* true to say and where the label for a reload goes. Calls in `except` are about
|
|
136
|
+
* to be answered by something else -- a resumed approval, or a navigation
|
|
137
|
+
* result -- and must not be answered twice.
|
|
138
|
+
*
|
|
139
|
+
* Returns `messages` itself when nothing is owed.
|
|
140
|
+
*
|
|
141
|
+
* Every condition deciding a round's end is one branch arc, which a coverage
|
|
142
|
+
* gate reports as covered with any part of it deleted. Each is held by a test in
|
|
143
|
+
* `ag_ui_chat_unanswered_tool_calls.test.ts`: a result continuing the round by
|
|
144
|
+
* "answers it after the round's own results"; a turn without calls closing it by
|
|
145
|
+
* "sends a not-finished result for a call that arrived before it"; a turn with
|
|
146
|
+
* calls after a result closing it by "answers each round's open call before the
|
|
147
|
+
* next round starts"; the answered check by "sends the decline Stop gave the
|
|
148
|
+
* open approval"; and the exclusion by "leaves the call it resumes for the
|
|
149
|
+
* server to answer".
|
|
150
|
+
*/
|
|
151
|
+
export function answerUnansweredCalls(
|
|
152
|
+
messages: readonly Message[],
|
|
153
|
+
answer: (toolCallId: string) => Message,
|
|
154
|
+
except: ReadonlySet<string> = new Set(),
|
|
155
|
+
): readonly Message[] {
|
|
156
|
+
const answered = new Set<string>(except);
|
|
157
|
+
for (const message of messages) {
|
|
158
|
+
if (message.role === "tool") {
|
|
159
|
+
answered.add(message.toolCallId);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const result: Message[] = [];
|
|
163
|
+
let owed: string[] = [];
|
|
164
|
+
let afterResult = false;
|
|
165
|
+
for (const message of messages) {
|
|
166
|
+
const calls = message.role === "assistant" ? toolCallIds(message.toolCalls) : [];
|
|
167
|
+
const continuesRound = message.role === "tool" || (calls.length > 0 && !afterResult);
|
|
168
|
+
if (!continuesRound) {
|
|
169
|
+
result.push(...owed.map(answer));
|
|
170
|
+
owed = [];
|
|
171
|
+
}
|
|
172
|
+
result.push(message);
|
|
173
|
+
afterResult = message.role === "tool";
|
|
174
|
+
for (const id of calls) {
|
|
175
|
+
if (!answered.has(id)) {
|
|
176
|
+
// Marked as it is owed, so a store holding the same id twice gets one
|
|
177
|
+
// answer rather than two, which a provider rejects just the same.
|
|
178
|
+
answered.add(id);
|
|
179
|
+
owed.push(id);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (result.length + owed.length === messages.length) {
|
|
184
|
+
return messages;
|
|
185
|
+
}
|
|
186
|
+
result.push(...owed.map(answer));
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The ids of the tool calls an assistant turn carries, skipping anything without
|
|
192
|
+
* one.
|
|
193
|
+
*
|
|
194
|
+
* The field is read off the wire or out of a store, and neither is held to its
|
|
195
|
+
* declared type: a server dumping Python models sends `null` for no calls, and a
|
|
196
|
+
* stored conversation may be hand-edited or written by an older version. A throw
|
|
197
|
+
* here would cost the whole request, so a shapeless entry is passed over.
|
|
198
|
+
*/
|
|
199
|
+
function toolCallIds(value: unknown): string[] {
|
|
200
|
+
// `Object()` boxes a `null` or a primitive entry into something with no `id`,
|
|
201
|
+
// so one read covers every shape without a branch per shape.
|
|
202
|
+
return Array.isArray(value)
|
|
203
|
+
? value
|
|
204
|
+
.map((call: unknown) => (Object(call) as { id?: unknown }).id)
|
|
205
|
+
.filter((id): id is string => typeof id === "string")
|
|
206
|
+
: [];
|
|
207
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { SkillsMenu } from "../ui/composer/skills_menu.js";
|
|
2
|
+
import { fillUiString } from "../ui/fill_ui_string.js";
|
|
3
|
+
import type { UiStrings } from "../ui/ui_strings.js";
|
|
4
|
+
import { fillTemplate } from "./fill_template.js";
|
|
5
|
+
import { parseSkills } from "./parse_skills.js";
|
|
6
|
+
import type { Skill } from "./skill.js";
|
|
7
|
+
|
|
8
|
+
/** What the skill catalog needs from the element that owns it. */
|
|
9
|
+
export interface SkillCatalogHost {
|
|
10
|
+
/** The custom element, whose `data-skills-url` names the backend catalog. */
|
|
11
|
+
readonly element: HTMLElement;
|
|
12
|
+
/** The chips row and the slash palette the merged catalog is shown in. */
|
|
13
|
+
readonly menu: SkillsMenu;
|
|
14
|
+
/** The composer a picked skill's prompt is written into. */
|
|
15
|
+
readonly input: HTMLTextAreaElement;
|
|
16
|
+
/** The hint above the composer, saying what a skill still needs. */
|
|
17
|
+
readonly hint: HTMLElement;
|
|
18
|
+
/** The resolved string table. */
|
|
19
|
+
readonly strings: () => UiStrings;
|
|
20
|
+
/** The host's `skillContext`, read at the moment of the pick. */
|
|
21
|
+
readonly context: () => Record<string, unknown>;
|
|
22
|
+
/** Read an opt-in flag attribute. */
|
|
23
|
+
readonly flag: (name: string) => boolean;
|
|
24
|
+
/** Parse a JSON-valued attribute, warning when it will not parse. */
|
|
25
|
+
readonly readJsonAttribute: (name: string) => unknown;
|
|
26
|
+
/** The fetch options every request the element makes carries. */
|
|
27
|
+
readonly fetchInit: (url: string) => RequestInit | undefined;
|
|
28
|
+
/** The element's public `sendMessage`. */
|
|
29
|
+
readonly send: (content: string) => void;
|
|
30
|
+
/** Send whatever the composer holds, as the Send button does. */
|
|
31
|
+
readonly submit: () => void;
|
|
32
|
+
/** Resize the composer to what it now holds. */
|
|
33
|
+
readonly autoGrow: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The element's skills: the three catalogs a skill can come from, merged into
|
|
38
|
+
* the chips and the slash palette, and what picking one does to the composer.
|
|
39
|
+
*
|
|
40
|
+
* Owned one-to-one by an `<ag-ui-chat>`, and holding no state outside the
|
|
41
|
+
* instance.
|
|
42
|
+
*/
|
|
43
|
+
export class SkillCatalog {
|
|
44
|
+
readonly #host: SkillCatalogHost;
|
|
45
|
+
// Skill catalog by source; merged backend → embed → client (later wins).
|
|
46
|
+
#backendSkills: readonly Skill[] = [];
|
|
47
|
+
#embedSkills: readonly Skill[] = [];
|
|
48
|
+
#clientSkills: readonly Skill[] = [];
|
|
49
|
+
|
|
50
|
+
constructor(host: SkillCatalogHost) {
|
|
51
|
+
this.#host = host;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The replacement behind `AgUiChat.setSkills`, whose doc is the contract. */
|
|
55
|
+
setClientSkills(skills: readonly Skill[]): void {
|
|
56
|
+
this.#clientSkills = skills;
|
|
57
|
+
this.#recompute();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Wire the skill surfaces: opt-in flags and the embedded catalog. The backend
|
|
62
|
+
* catalog is fetched from the element's startup, a microtask later, so it
|
|
63
|
+
* carries the host's transport configuration.
|
|
64
|
+
*/
|
|
65
|
+
init(): void {
|
|
66
|
+
this.#host.menu.enableChips(this.#host.flag("data-prompt-chips"));
|
|
67
|
+
this.#host.menu.enableSlash(this.#host.flag("data-slash-commands"));
|
|
68
|
+
this.#embedSkills = this.#readEmbedded();
|
|
69
|
+
this.#recompute();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Fetch the backend skills catalog from `data-skills-url`, if set. */
|
|
73
|
+
async fetch(): Promise<void> {
|
|
74
|
+
const url = this.#host.element.getAttribute("data-skills-url");
|
|
75
|
+
if (url === null) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const response = await fetch(url, this.#host.fetchInit(url));
|
|
80
|
+
this.#backendSkills = parseSkills(await response.json());
|
|
81
|
+
this.#recompute();
|
|
82
|
+
} catch {
|
|
83
|
+
// Network/parse failure: skills just stay as the embedded/client set.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Act on a picked skill.
|
|
89
|
+
*
|
|
90
|
+
* A skill with no `prompt` is server-resolved: picking it sends the bare
|
|
91
|
+
* `/name` token for the agent to expand, so the wording never reaches the
|
|
92
|
+
* browser. Prefer that shape — a skill often states a project's internal
|
|
93
|
+
* workflow most plainly, and a catalog endpoint is a plain GET.
|
|
94
|
+
*
|
|
95
|
+
* A skill carrying a `prompt` has the client fill its `{placeholder}`s from
|
|
96
|
+
* the page instead, which is right for placeholders only the page can supply.
|
|
97
|
+
*
|
|
98
|
+
* Either way a pick sends; `sendImmediately: false` opts into pre-filling the
|
|
99
|
+
* composer instead.
|
|
100
|
+
*/
|
|
101
|
+
apply(skill: Skill): void {
|
|
102
|
+
const { input, hint } = this.#host;
|
|
103
|
+
if (skill.prompt === undefined) {
|
|
104
|
+
hint.hidden = true;
|
|
105
|
+
this.#host.send(`/${skill.name}`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const { text, missing } = fillTemplate(skill.prompt, this.#host.context());
|
|
109
|
+
if (missing.length > 0) {
|
|
110
|
+
// Hand the user something to work with rather than only a refusal. The
|
|
111
|
+
// partially-filled template goes into the composer with its unresolved
|
|
112
|
+
// `{placeholder}`s intact and the first one selected, so the next
|
|
113
|
+
// keystroke replaces it. Blocking with a hint alone left whatever the
|
|
114
|
+
// user had typed to open the palette — a lone "/" — sitting there, which
|
|
115
|
+
// says nothing about what the skill wanted or how to give it.
|
|
116
|
+
hint.textContent = fillUiString(this.#host.strings().skillNeeds, {
|
|
117
|
+
title: skill.title,
|
|
118
|
+
fields: missing.join(", "),
|
|
119
|
+
});
|
|
120
|
+
hint.hidden = false;
|
|
121
|
+
input.value = text;
|
|
122
|
+
this.#host.autoGrow();
|
|
123
|
+
input.focus();
|
|
124
|
+
this.#selectFirstPlaceholder(text);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
hint.hidden = true;
|
|
128
|
+
input.value = text;
|
|
129
|
+
this.#host.autoGrow();
|
|
130
|
+
if (skill.sendImmediately === false) {
|
|
131
|
+
input.focus();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
this.#host.submit();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Parse the inline `data-skills` JSON catalog (empty when absent/malformed). */
|
|
138
|
+
#readEmbedded(): readonly Skill[] {
|
|
139
|
+
// `parseSkills` drops anything that is not a well-formed skill, `null`
|
|
140
|
+
// included, so the absent and unparseable cases need no branch here.
|
|
141
|
+
return parseSkills(this.#host.readJsonAttribute("data-skills"));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Merge the three sources (backend → embed → client; later wins by name). */
|
|
145
|
+
#recompute(): void {
|
|
146
|
+
const merged = new Map<string, Skill>();
|
|
147
|
+
for (const skill of [...this.#backendSkills, ...this.#embedSkills, ...this.#clientSkills]) {
|
|
148
|
+
merged.set(skill.name, skill);
|
|
149
|
+
}
|
|
150
|
+
this.#host.menu.setSkills([...merged.values()]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Put the caret on the first unresolved placeholder, selected.
|
|
155
|
+
*
|
|
156
|
+
* Typing then replaces it, which is the shortest path from "this skill needs
|
|
157
|
+
* a topic" to a sendable prompt.
|
|
158
|
+
*/
|
|
159
|
+
#selectFirstPlaceholder(text: string): void {
|
|
160
|
+
// The first surviving brace *is* the first unresolved placeholder — a
|
|
161
|
+
// resolved one was substituted away — so this needs no search through the
|
|
162
|
+
// missing keys and no not-found branch to defend.
|
|
163
|
+
const start = text.indexOf("{");
|
|
164
|
+
this.#host.input.setSelectionRange(start, text.indexOf("}", start) + 1);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { LOAD_CAPABILITY_TOOL } from "../constants.js";
|
|
2
|
+
import type { AgUiToolCall } from "../core/agui_client.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The skill name a `load_capability` call activated, or `null` when the call is
|
|
6
|
+
* something else.
|
|
7
|
+
*
|
|
8
|
+
* Every deferred capability loads through this one tool, so the id is a skill
|
|
9
|
+
* name only when the project wired agent skills; another project's capability
|
|
10
|
+
* id surfaces here too. Acceptable for a muted notice, and better than a
|
|
11
|
+
* parallel signal — the id is exactly what the model selected.
|
|
12
|
+
*/
|
|
13
|
+
export function skillNameFrom(call: AgUiToolCall): string | null {
|
|
14
|
+
if (call.name !== LOAD_CAPABILITY_TOOL) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const id = (call.args as { id?: unknown } | null | undefined)?.id;
|
|
18
|
+
return typeof id === "string" && id !== "" ? id : null;
|
|
19
|
+
}
|