@artooi/ag-ui-web-component 0.10.0 → 0.11.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 +44 -1
- package/README.md +89 -7
- package/dist/ag-ui-web-component.bundle.js +175 -54
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/core/ag_ui_chat.d.ts +29 -0
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +28 -1
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +589 -72
- package/dist/index.js.map +4 -4
- package/dist/ui/approval_card.d.ts +51 -0
- package/dist/ui/approval_card.d.ts.map +1 -0
- package/dist/ui/attachment_chips.d.ts.map +1 -1
- package/dist/ui/attachment_tray.d.ts.map +1 -1
- package/dist/ui/question_card.d.ts +52 -0
- package/dist/ui/question_card.d.ts.map +1 -0
- package/dist/ui/skills_menu.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/thoughts_block.d.ts.map +1 -1
- package/dist/ui/thread_drawer.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +16 -0
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/core/ag_ui_chat.ts +172 -3
- package/src/core/agui_client.ts +86 -11
- package/src/index.ts +14 -0
- package/src/ui/approval_card.ts +119 -0
- package/src/ui/attachment_chips.ts +5 -0
- package/src/ui/attachment_tray.ts +8 -0
- package/src/ui/question_card.ts +216 -0
- package/src/ui/skills_menu.ts +6 -0
- package/src/ui/styles.ts +121 -0
- package/src/ui/thoughts_block.ts +1 -0
- package/src/ui/thread_drawer.ts +11 -0
- package/src/ui/ui_strings.ts +30 -0
- package/src/version.ts +1 -1
package/src/core/agui_client.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
type AbstractAgent,
|
|
3
|
+
type AgentSubscriber,
|
|
4
|
+
buildResumeArray,
|
|
5
|
+
type RunAgentParameters,
|
|
6
|
+
randomUUID,
|
|
7
|
+
} from "@ag-ui/client";
|
|
8
|
+
import type { Context, Interrupt, Message, ResumeEntry, Tool } from "@ag-ui/core";
|
|
3
9
|
import { MAX_TOOL_ROUNDS } from "../constants.js";
|
|
4
10
|
import type { AttachmentRef } from "./attachment.js";
|
|
5
11
|
|
|
@@ -33,6 +39,27 @@ export interface ToolExecution {
|
|
|
33
39
|
*/
|
|
34
40
|
export type ExecuteTool = (call: AgUiToolCall) => Promise<ToolExecution | null>;
|
|
35
41
|
|
|
42
|
+
/**
|
|
43
|
+
* One user decision for a server-side-tool approval interrupt. Structurally
|
|
44
|
+
* matches `@ag-ui/client`'s (non-exported) `ResumeResponse`, the payload
|
|
45
|
+
* {@link buildResumeArray} turns into a `ResumeEntry`: `resolved` approves (with
|
|
46
|
+
* an optional `payload`, e.g. `{ approved: true }`), `cancelled` denies.
|
|
47
|
+
*/
|
|
48
|
+
export type InterruptResponse = { status: "resolved"; payload?: unknown } | { status: "cancelled" };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves the approval interrupts a run finished on, keyed by interrupt id.
|
|
52
|
+
*
|
|
53
|
+
* When a gated **server-side** tool defers instead of executing, the run
|
|
54
|
+
* finishes on an AG-UI interrupt outcome; the host renders an approval card per
|
|
55
|
+
* interrupt and returns each decision here, and the loop resumes the run with
|
|
56
|
+
* the answers. Omit for agents that never gate server-side tools — an
|
|
57
|
+
* unresolved interrupt then simply ends the loop.
|
|
58
|
+
*/
|
|
59
|
+
export type ResolveInterrupts = (
|
|
60
|
+
interrupts: readonly Interrupt[],
|
|
61
|
+
) => Promise<Record<string, InterruptResponse>>;
|
|
62
|
+
|
|
36
63
|
/**
|
|
37
64
|
* Callbacks the {@link AgUiClient} invokes as a run progresses. The host
|
|
38
65
|
* (the `<ag-ui-chat>` element) implements these to render streaming text and
|
|
@@ -93,6 +120,11 @@ export interface AgUiClientConfig extends AgUiRunInputs {
|
|
|
93
120
|
handlers: AgUiClientHandlers;
|
|
94
121
|
/** Executes frontend tool calls. Omit for server-only tool sets. */
|
|
95
122
|
executeTool?: ExecuteTool;
|
|
123
|
+
/**
|
|
124
|
+
* Resolves server-side-tool approval interrupts. Omit when no server-side
|
|
125
|
+
* tool is gated for approval — an interrupt then ends the loop unanswered.
|
|
126
|
+
*/
|
|
127
|
+
resolveInterrupts?: ResolveInterrupts;
|
|
96
128
|
/**
|
|
97
129
|
* Invoked with the latest history whenever it changes, so the host can
|
|
98
130
|
* persist it for durability across page reloads. Omit to keep the
|
|
@@ -134,6 +166,7 @@ export class AgUiClient {
|
|
|
134
166
|
readonly #getTools: () => Tool[];
|
|
135
167
|
readonly #getContext: () => Context[];
|
|
136
168
|
readonly #executeTool: ExecuteTool | null;
|
|
169
|
+
readonly #resolveInterrupts: ResolveInterrupts | null;
|
|
137
170
|
readonly #onPersist: (messages: readonly Message[]) => void;
|
|
138
171
|
readonly #connectionLostMessage: string;
|
|
139
172
|
// Set by cancel(); reset at the top of each #run(). Checked by the loop so
|
|
@@ -146,6 +179,7 @@ export class AgUiClient {
|
|
|
146
179
|
this.#getTools = config.getTools ?? (() => []);
|
|
147
180
|
this.#getContext = config.getContext ?? (() => []);
|
|
148
181
|
this.#executeTool = config.executeTool ?? null;
|
|
182
|
+
this.#resolveInterrupts = config.resolveInterrupts ?? null;
|
|
149
183
|
this.#onPersist = config.onPersist ?? (() => {});
|
|
150
184
|
this.#connectionLostMessage = config.connectionLostMessage ?? "Connection lost";
|
|
151
185
|
}
|
|
@@ -241,6 +275,11 @@ export class AgUiClient {
|
|
|
241
275
|
}
|
|
242
276
|
|
|
243
277
|
async #runLoop(): Promise<void> {
|
|
278
|
+
// Carries the resolved approval answers into the *next* run when a round
|
|
279
|
+
// finished on a server-side-tool interrupt. Distinct from the public
|
|
280
|
+
// resume() navigation-reload path (which continues an unfinished
|
|
281
|
+
// frontend-tool round after a page load) — this stays inside one #run().
|
|
282
|
+
let resume: ResumeEntry[] | undefined;
|
|
244
283
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
|
245
284
|
// A cancel during the previous round's frontend-tool execution lands
|
|
246
285
|
// here: the running handler completed, but no further round starts.
|
|
@@ -248,11 +287,16 @@ export class AgUiClient {
|
|
|
248
287
|
return;
|
|
249
288
|
}
|
|
250
289
|
const pending: AgUiToolCall[] = [];
|
|
251
|
-
const runState = { terminal: false, errored: false };
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
this.#
|
|
255
|
-
|
|
290
|
+
const runState: RunState = { terminal: false, errored: false, interrupts: [] };
|
|
291
|
+
const params: RunAgentParameters = {
|
|
292
|
+
tools: this.#getTools(),
|
|
293
|
+
context: this.#getContext(),
|
|
294
|
+
};
|
|
295
|
+
if (resume !== undefined) {
|
|
296
|
+
params.resume = resume;
|
|
297
|
+
}
|
|
298
|
+
await this.#agent.runAgent(params, this.#buildSubscriber(pending, runState));
|
|
299
|
+
resume = undefined;
|
|
256
300
|
this.#onPersist(this.#agent.messages);
|
|
257
301
|
// Cancelled mid-stream: the user said stop — don't execute the tool
|
|
258
302
|
// calls collected before the abort.
|
|
@@ -272,6 +316,22 @@ export class AgUiClient {
|
|
|
272
316
|
if (runState.errored) {
|
|
273
317
|
return;
|
|
274
318
|
}
|
|
319
|
+
// A gated server-side tool deferred instead of executing: the run finished
|
|
320
|
+
// on an interrupt outcome. Ask the host to resolve each interrupt, then
|
|
321
|
+
// re-enter the loop carrying the answers — the follow-up run runs or denies
|
|
322
|
+
// the tool (its result streams back as TOOL_CALL_RESULT). Takes precedence
|
|
323
|
+
// over the frontend-tool sweep below: a server-side tool isn't ours to run.
|
|
324
|
+
if (runState.interrupts.length > 0) {
|
|
325
|
+
if (this.#resolveInterrupts === null) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const responses = await this.#resolveInterrupts(runState.interrupts);
|
|
329
|
+
if (this.#cancelled) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
resume = buildResumeArray(runState.interrupts, responses);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
275
335
|
if (this.#executeTool === null || pending.length === 0) {
|
|
276
336
|
return;
|
|
277
337
|
}
|
|
@@ -301,10 +361,7 @@ export class AgUiClient {
|
|
|
301
361
|
}
|
|
302
362
|
}
|
|
303
363
|
|
|
304
|
-
#buildSubscriber(
|
|
305
|
-
pending: AgUiToolCall[],
|
|
306
|
-
runState: { terminal: boolean; errored: boolean },
|
|
307
|
-
): AgentSubscriber {
|
|
364
|
+
#buildSubscriber(pending: AgUiToolCall[], runState: RunState): AgentSubscriber {
|
|
308
365
|
const h = this.#handlers;
|
|
309
366
|
return {
|
|
310
367
|
onRunInitialized() {
|
|
@@ -340,6 +397,16 @@ export class AgUiClient {
|
|
|
340
397
|
onReasoningEndEvent() {
|
|
341
398
|
h.onReasoningEnd();
|
|
342
399
|
},
|
|
400
|
+
onRunFinishedEvent(params) {
|
|
401
|
+
// RUN_FINISHED is terminal for both a normal finish and an interrupt.
|
|
402
|
+
// Capturing the interrupts here (rather than reading the agent's
|
|
403
|
+
// `pendingInterrupts` field afterwards) keeps the loop self-contained
|
|
404
|
+
// and independent of that field's cross-run clearing semantics.
|
|
405
|
+
runState.terminal = true;
|
|
406
|
+
if (params.outcome === "interrupt") {
|
|
407
|
+
runState.interrupts = params.interrupts;
|
|
408
|
+
}
|
|
409
|
+
},
|
|
343
410
|
onRunErrorEvent({ event }) {
|
|
344
411
|
runState.terminal = true;
|
|
345
412
|
runState.errored = true;
|
|
@@ -353,6 +420,14 @@ export class AgUiClient {
|
|
|
353
420
|
}
|
|
354
421
|
}
|
|
355
422
|
|
|
423
|
+
/** Per-run mutable flags the subscriber writes and {@link AgUiClient} reads. */
|
|
424
|
+
interface RunState {
|
|
425
|
+
terminal: boolean;
|
|
426
|
+
errored: boolean;
|
|
427
|
+
/** Approval interrupts a run finished on (empty for a normal finish). */
|
|
428
|
+
interrupts: Interrupt[];
|
|
429
|
+
}
|
|
430
|
+
|
|
356
431
|
/**
|
|
357
432
|
* Whether a rejection came from aborting the run's fetch. Belt-and-suspenders
|
|
358
433
|
* with the `#cancelled` flag: some `@ag-ui/client` versions re-throw the
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,8 @@ export {
|
|
|
27
27
|
type AgUiToolCall,
|
|
28
28
|
ConnectionLostError,
|
|
29
29
|
type ExecuteTool,
|
|
30
|
+
type InterruptResponse,
|
|
31
|
+
type ResolveInterrupts,
|
|
30
32
|
type ToolExecution,
|
|
31
33
|
} from "./core/agui_client.js";
|
|
32
34
|
export { type AttachmentRef, messageAttachments } from "./core/attachment.js";
|
|
@@ -98,12 +100,24 @@ export {
|
|
|
98
100
|
type RouteWithParams,
|
|
99
101
|
} from "./tools/route_map.js";
|
|
100
102
|
export { createStateHookTools, type StateHook } from "./tools/state_hook.js";
|
|
103
|
+
export {
|
|
104
|
+
type ApprovalOptions,
|
|
105
|
+
type ApprovalRenderer,
|
|
106
|
+
type ApprovalRequest,
|
|
107
|
+
requestApproval,
|
|
108
|
+
} from "./ui/approval_card.js";
|
|
101
109
|
export {
|
|
102
110
|
type ConfirmationOptions,
|
|
103
111
|
type ConfirmationRequest,
|
|
104
112
|
requestConfirmation,
|
|
105
113
|
} from "./ui/confirmation_card.js";
|
|
106
114
|
export { prettifyToolName } from "./ui/prettify_tool_name.js";
|
|
115
|
+
export {
|
|
116
|
+
type QuestionOptions,
|
|
117
|
+
type QuestionRenderer,
|
|
118
|
+
type QuestionRequest,
|
|
119
|
+
requestQuestion,
|
|
120
|
+
} from "./ui/question_card.js";
|
|
107
121
|
export { type RenderMarkdownOptions, renderMarkdown } from "./ui/render_markdown.js";
|
|
108
122
|
export {
|
|
109
123
|
type SettledStatus,
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
|
|
2
|
+
|
|
3
|
+
/** What the inline approval card displays for one server-side-tool interrupt. */
|
|
4
|
+
export interface ApprovalRequest {
|
|
5
|
+
/**
|
|
6
|
+
* Human-readable prompt from the AG-UI interrupt (e.g.
|
|
7
|
+
* `Approve delete_thing({…})?`). Falls back to the generic
|
|
8
|
+
* {@link UiStrings.approvalPrompt} when the interrupt carries no message.
|
|
9
|
+
*/
|
|
10
|
+
message?: string;
|
|
11
|
+
/** Tool name, surfaced as a `data-tool-name` attribute for styling/tests. */
|
|
12
|
+
toolName?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Build a labelled action button. */
|
|
16
|
+
function actionButton(modifier: string, label: string): HTMLButtonElement {
|
|
17
|
+
const button = document.createElement("button");
|
|
18
|
+
button.type = "button";
|
|
19
|
+
button.className = `approval-btn approval-btn--${modifier}`;
|
|
20
|
+
button.setAttribute("part", `approval-button approval-${modifier}`);
|
|
21
|
+
button.textContent = label;
|
|
22
|
+
return button;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Options for {@link requestApproval}. */
|
|
26
|
+
export interface ApprovalOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Aborting this signal resolves the card as **denied** (buttons disabled,
|
|
29
|
+
* `data-resolved="denied"`) — the hook a Stop control uses to dismiss a
|
|
30
|
+
* pending approval when the user cancels the whole run.
|
|
31
|
+
*/
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
/** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
|
|
34
|
+
strings?: UiStrings;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A fully custom renderer for a server-side-tool approval, set via
|
|
39
|
+
* `AgUiChat.approvalRenderer`. Receives the {@link ApprovalRequest} (the
|
|
40
|
+
* interrupt's message + tool name) and an `AbortSignal` that fires when the run
|
|
41
|
+
* is stopped, and resolves `true` to approve or `false` to deny. When provided
|
|
42
|
+
* it **replaces** the built-in {@link requestApproval} card entirely — the host
|
|
43
|
+
* owns the DOM, so it can render a native modal, a framework component, or
|
|
44
|
+
* anything else. See the `strings` / `::part()` seams for styling the built-in
|
|
45
|
+
* card instead of replacing it.
|
|
46
|
+
*/
|
|
47
|
+
export type ApprovalRenderer = (
|
|
48
|
+
request: ApprovalRequest,
|
|
49
|
+
options: { signal: AbortSignal },
|
|
50
|
+
) => Promise<boolean>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Append an inline **approval** card to ``host`` and resolve when the user
|
|
54
|
+
* decides whether a gated *server-side* tool may run.
|
|
55
|
+
*
|
|
56
|
+
* The server-side approval gate is distinct from the client-tool confirmation
|
|
57
|
+
* card ({@link requestConfirmation}): a destructive server tool defers instead
|
|
58
|
+
* of executing, and the run finishes on an AG-UI *interrupt* the client answers
|
|
59
|
+
* with `resume[]`. This card is the browser half of that loop — it reads
|
|
60
|
+
* naturally after the tool-call card whose execution it gates, and resolves
|
|
61
|
+
* ``true`` to approve (run the tool) or ``false`` to deny. The card stays in the
|
|
62
|
+
* transcript as a resolved record (buttons disabled, `data-resolved` set)
|
|
63
|
+
* rather than vanishing.
|
|
64
|
+
*/
|
|
65
|
+
export function requestApproval(
|
|
66
|
+
host: Node & ParentNode,
|
|
67
|
+
request: ApprovalRequest,
|
|
68
|
+
options: ApprovalOptions = {},
|
|
69
|
+
): Promise<boolean> {
|
|
70
|
+
const strings = options.strings ?? DEFAULT_UI_STRINGS;
|
|
71
|
+
return new Promise<boolean>((resolve) => {
|
|
72
|
+
const card = document.createElement("div");
|
|
73
|
+
card.className = "approval";
|
|
74
|
+
card.setAttribute("part", "approval");
|
|
75
|
+
if (request.toolName !== undefined) {
|
|
76
|
+
card.setAttribute("data-tool-name", request.toolName);
|
|
77
|
+
}
|
|
78
|
+
card.setAttribute("role", "group");
|
|
79
|
+
card.setAttribute("aria-label", strings.approveAction);
|
|
80
|
+
|
|
81
|
+
const body = document.createElement("div");
|
|
82
|
+
body.className = "approval-body";
|
|
83
|
+
body.setAttribute("part", "approval-body");
|
|
84
|
+
body.textContent = request.message ?? strings.approvalPrompt;
|
|
85
|
+
|
|
86
|
+
const actions = document.createElement("div");
|
|
87
|
+
actions.className = "approval-actions";
|
|
88
|
+
actions.setAttribute("part", "approval-actions");
|
|
89
|
+
|
|
90
|
+
const deny = actionButton("deny", strings.deny);
|
|
91
|
+
const approve = actionButton("approve", strings.approve);
|
|
92
|
+
|
|
93
|
+
let settled = false;
|
|
94
|
+
const close = (approved: boolean): void => {
|
|
95
|
+
if (settled) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
settled = true;
|
|
99
|
+
deny.disabled = true;
|
|
100
|
+
approve.disabled = true;
|
|
101
|
+
card.setAttribute("data-resolved", approved ? "approved" : "denied");
|
|
102
|
+
resolve(approved);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
deny.addEventListener("click", () => close(false));
|
|
106
|
+
approve.addEventListener("click", () => close(true));
|
|
107
|
+
options.signal?.addEventListener("abort", () => close(false), { once: true });
|
|
108
|
+
|
|
109
|
+
actions.append(deny, approve);
|
|
110
|
+
card.append(body, actions);
|
|
111
|
+
host.appendChild(card);
|
|
112
|
+
if (options.signal?.aborted === true) {
|
|
113
|
+
// The run was cancelled before the card could ask; record the denial.
|
|
114
|
+
close(false);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
approve.focus();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -9,6 +9,7 @@ import type { AttachmentRef } from "../core/attachment.js";
|
|
|
9
9
|
export function renderAttachmentChips(refs: readonly AttachmentRef[]): HTMLDivElement {
|
|
10
10
|
const list = document.createElement("div");
|
|
11
11
|
list.className = "attachment-chips";
|
|
12
|
+
list.setAttribute("part", "attachment-chips");
|
|
12
13
|
for (const ref of refs) {
|
|
13
14
|
list.appendChild(renderChip(ref));
|
|
14
15
|
}
|
|
@@ -18,19 +19,23 @@ export function renderAttachmentChips(refs: readonly AttachmentRef[]): HTMLDivEl
|
|
|
18
19
|
function renderChip(ref: AttachmentRef): HTMLDivElement {
|
|
19
20
|
const chip = document.createElement("div");
|
|
20
21
|
chip.className = "attachment-chip attachment-chip--ready";
|
|
22
|
+
chip.setAttribute("part", "attachment-chip");
|
|
21
23
|
|
|
22
24
|
const icon = document.createElement("span");
|
|
23
25
|
icon.className = "attachment-chip-icon";
|
|
26
|
+
icon.setAttribute("part", "attachment-chip-icon");
|
|
24
27
|
icon.textContent = iconFor(ref.mime);
|
|
25
28
|
icon.setAttribute("aria-hidden", "true");
|
|
26
29
|
|
|
27
30
|
const name = document.createElement("span");
|
|
28
31
|
name.className = "attachment-chip-name";
|
|
32
|
+
name.setAttribute("part", "attachment-chip-name");
|
|
29
33
|
name.textContent = ref.name;
|
|
30
34
|
name.title = ref.name;
|
|
31
35
|
|
|
32
36
|
const size = document.createElement("span");
|
|
33
37
|
size.className = "attachment-chip-size";
|
|
38
|
+
size.setAttribute("part", "attachment-chip-size");
|
|
34
39
|
size.textContent = formatBytes(ref.size);
|
|
35
40
|
|
|
36
41
|
chip.append(icon, name, size);
|
|
@@ -205,19 +205,23 @@ export class AttachmentTray {
|
|
|
205
205
|
#renderChip(item: TrayItem): HTMLDivElement {
|
|
206
206
|
const chip = document.createElement("div");
|
|
207
207
|
chip.className = `attachment-chip attachment-chip--${item.status}`;
|
|
208
|
+
chip.setAttribute("part", "attachment-chip");
|
|
208
209
|
|
|
209
210
|
const icon = document.createElement("span");
|
|
210
211
|
icon.className = "attachment-chip-icon";
|
|
212
|
+
icon.setAttribute("part", "attachment-chip-icon");
|
|
211
213
|
icon.textContent = iconFor(item.file.type);
|
|
212
214
|
icon.setAttribute("aria-hidden", "true");
|
|
213
215
|
|
|
214
216
|
const name = document.createElement("span");
|
|
215
217
|
name.className = "attachment-chip-name";
|
|
218
|
+
name.setAttribute("part", "attachment-chip-name");
|
|
216
219
|
name.textContent = item.file.name;
|
|
217
220
|
name.title = item.file.name;
|
|
218
221
|
|
|
219
222
|
const meta = document.createElement("span");
|
|
220
223
|
meta.className = "attachment-chip-size";
|
|
224
|
+
meta.setAttribute("part", "attachment-chip-size");
|
|
221
225
|
meta.textContent =
|
|
222
226
|
item.status === ATTACHMENT_STATUS.ERROR ? item.error : formatBytes(item.file.size);
|
|
223
227
|
|
|
@@ -226,8 +230,10 @@ export class AttachmentTray {
|
|
|
226
230
|
if (item.status === ATTACHMENT_STATUS.UPLOADING) {
|
|
227
231
|
const bar = document.createElement("div");
|
|
228
232
|
bar.className = "attachment-chip-bar";
|
|
233
|
+
bar.setAttribute("part", "attachment-chip-bar");
|
|
229
234
|
const fill = document.createElement("div");
|
|
230
235
|
fill.className = "attachment-chip-bar-fill";
|
|
236
|
+
fill.setAttribute("part", "attachment-chip-bar-fill");
|
|
231
237
|
fill.style.width = `${Math.round(item.progress * 100)}%`;
|
|
232
238
|
bar.appendChild(fill);
|
|
233
239
|
chip.appendChild(bar);
|
|
@@ -237,6 +243,7 @@ export class AttachmentTray {
|
|
|
237
243
|
const retry = document.createElement("button");
|
|
238
244
|
retry.type = "button";
|
|
239
245
|
retry.className = "attachment-chip-retry";
|
|
246
|
+
retry.setAttribute("part", "attachment-chip-retry");
|
|
240
247
|
retry.title = this.#strings.retry;
|
|
241
248
|
retry.setAttribute("aria-label", this.#strings.retryUpload);
|
|
242
249
|
retry.textContent = "↻";
|
|
@@ -247,6 +254,7 @@ export class AttachmentTray {
|
|
|
247
254
|
const remove = document.createElement("button");
|
|
248
255
|
remove.type = "button";
|
|
249
256
|
remove.className = "attachment-chip-remove";
|
|
257
|
+
remove.setAttribute("part", "attachment-chip-remove");
|
|
250
258
|
remove.title = this.#strings.remove;
|
|
251
259
|
remove.setAttribute("aria-label", this.#strings.removeAttachment);
|
|
252
260
|
remove.textContent = "✕";
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
|
|
2
|
+
|
|
3
|
+
/** What the inline question card asks (the `ask_user` frontend tool's args). */
|
|
4
|
+
export interface QuestionRequest {
|
|
5
|
+
/** The question shown to the user. */
|
|
6
|
+
question: string;
|
|
7
|
+
/**
|
|
8
|
+
* Preset choices rendered as radios. When empty/omitted the card is a plain
|
|
9
|
+
* free-text prompt.
|
|
10
|
+
*/
|
|
11
|
+
options?: readonly string[];
|
|
12
|
+
/**
|
|
13
|
+
* Whether the user may type a custom answer. With `options`, adds an "other"
|
|
14
|
+
* radio revealing a text field; without options the card is free-text anyway.
|
|
15
|
+
*/
|
|
16
|
+
allowCustom?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Options for {@link requestQuestion}. */
|
|
20
|
+
export interface QuestionOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Aborting this signal resolves the card with an empty answer (fields
|
|
23
|
+
* disabled) — the hook a Stop control uses to dismiss an open question when
|
|
24
|
+
* the user cancels the whole run.
|
|
25
|
+
*/
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
/** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
|
|
28
|
+
strings?: UiStrings;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A fully custom renderer for the `ask_user` question, set via
|
|
33
|
+
* `AgUiChat.askUserRenderer`. Receives the parsed {@link QuestionRequest} and an
|
|
34
|
+
* `AbortSignal` that fires when the run is stopped, and resolves with the user's
|
|
35
|
+
* answer (an empty string signals "no answer", e.g. on abort). When provided it
|
|
36
|
+
* **replaces** the built-in {@link requestQuestion} card entirely — the host owns
|
|
37
|
+
* the DOM, so it can render a native modal, a framework component, or anything
|
|
38
|
+
* else. See the `strings` / `::part()` seams for styling the built-in card
|
|
39
|
+
* instead of replacing it.
|
|
40
|
+
*/
|
|
41
|
+
export type QuestionRenderer = (
|
|
42
|
+
request: QuestionRequest,
|
|
43
|
+
options: { signal: AbortSignal },
|
|
44
|
+
) => Promise<string>;
|
|
45
|
+
|
|
46
|
+
/** A single custom-answer text field. */
|
|
47
|
+
function answerInput(placeholder: string): HTMLInputElement {
|
|
48
|
+
const input = document.createElement("input");
|
|
49
|
+
input.type = "text";
|
|
50
|
+
input.className = "question-input";
|
|
51
|
+
input.setAttribute("part", "question-input");
|
|
52
|
+
input.placeholder = placeholder;
|
|
53
|
+
return input;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Append an inline **question** card to ``host`` and resolve with the user's
|
|
58
|
+
* answer — the browser half of the built-in `ask_user` frontend tool.
|
|
59
|
+
*
|
|
60
|
+
* Unlike the confirmation/approval cards (which resolve a yes/no), this collects
|
|
61
|
+
* a typed answer: a radio pick from ``options``, or free text (when
|
|
62
|
+
* ``allowCustom`` or no ``options`` are given). The card stays in the transcript
|
|
63
|
+
* as a resolved record (controls disabled, `data-resolved` set) rather than
|
|
64
|
+
* vanishing. A Stop while it is open resolves it with an empty string.
|
|
65
|
+
*/
|
|
66
|
+
export function requestQuestion(
|
|
67
|
+
host: Node & ParentNode,
|
|
68
|
+
request: QuestionRequest,
|
|
69
|
+
options: QuestionOptions = {},
|
|
70
|
+
): Promise<string> {
|
|
71
|
+
const strings = options.strings ?? DEFAULT_UI_STRINGS;
|
|
72
|
+
const choices = request.options ?? [];
|
|
73
|
+
const hasChoices = choices.length > 0;
|
|
74
|
+
// Free text is offered when there are no preset choices, or when custom
|
|
75
|
+
// answers are explicitly allowed alongside them (via an "other" radio).
|
|
76
|
+
const allowsText = !hasChoices || request.allowCustom === true;
|
|
77
|
+
|
|
78
|
+
return new Promise<string>((resolve) => {
|
|
79
|
+
const card = document.createElement("div");
|
|
80
|
+
card.className = "question";
|
|
81
|
+
card.setAttribute("part", "question");
|
|
82
|
+
card.setAttribute("role", "group");
|
|
83
|
+
card.setAttribute("aria-label", strings.askUserAction);
|
|
84
|
+
|
|
85
|
+
const body = document.createElement("div");
|
|
86
|
+
body.className = "question-body";
|
|
87
|
+
body.setAttribute("part", "question-body");
|
|
88
|
+
body.textContent = request.question;
|
|
89
|
+
|
|
90
|
+
const form = document.createElement("div");
|
|
91
|
+
form.className = "question-options";
|
|
92
|
+
form.setAttribute("part", "question-options");
|
|
93
|
+
|
|
94
|
+
// `name` scopes the radio group to this card so multiple open cards don't
|
|
95
|
+
// interfere; a per-card token keeps it unique without module state.
|
|
96
|
+
const group = `q-${choices.length}-${request.question.length}`;
|
|
97
|
+
const radios: HTMLInputElement[] = [];
|
|
98
|
+
for (const choice of choices) {
|
|
99
|
+
const label = document.createElement("label");
|
|
100
|
+
label.className = "question-choice";
|
|
101
|
+
label.setAttribute("part", "question-choice");
|
|
102
|
+
const radio = document.createElement("input");
|
|
103
|
+
radio.type = "radio";
|
|
104
|
+
radio.name = group;
|
|
105
|
+
radio.value = choice;
|
|
106
|
+
radio.setAttribute("part", "question-radio");
|
|
107
|
+
const text = document.createElement("span");
|
|
108
|
+
text.setAttribute("part", "question-choice-text");
|
|
109
|
+
text.textContent = choice;
|
|
110
|
+
label.append(radio, text);
|
|
111
|
+
form.appendChild(label);
|
|
112
|
+
radios.push(radio);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The "other" radio (only alongside choices) toggles the free-text field.
|
|
116
|
+
let otherRadio: HTMLInputElement | null = null;
|
|
117
|
+
let input: HTMLInputElement | null = null;
|
|
118
|
+
if (allowsText) {
|
|
119
|
+
input = answerInput(strings.answerPlaceholder);
|
|
120
|
+
if (hasChoices) {
|
|
121
|
+
const label = document.createElement("label");
|
|
122
|
+
label.className = "question-choice";
|
|
123
|
+
label.setAttribute("part", "question-choice");
|
|
124
|
+
otherRadio = document.createElement("input");
|
|
125
|
+
otherRadio.type = "radio";
|
|
126
|
+
otherRadio.name = group;
|
|
127
|
+
otherRadio.value = "";
|
|
128
|
+
otherRadio.setAttribute("part", "question-radio");
|
|
129
|
+
const text = document.createElement("span");
|
|
130
|
+
text.setAttribute("part", "question-choice-text");
|
|
131
|
+
text.textContent = strings.otherOption;
|
|
132
|
+
label.append(otherRadio, text);
|
|
133
|
+
form.appendChild(label);
|
|
134
|
+
input.disabled = true;
|
|
135
|
+
}
|
|
136
|
+
form.appendChild(input);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const actions = document.createElement("div");
|
|
140
|
+
actions.className = "question-actions";
|
|
141
|
+
actions.setAttribute("part", "question-actions");
|
|
142
|
+
const submit = document.createElement("button");
|
|
143
|
+
submit.type = "button";
|
|
144
|
+
submit.className = "question-btn";
|
|
145
|
+
submit.setAttribute("part", "question-button");
|
|
146
|
+
submit.textContent = strings.submit;
|
|
147
|
+
actions.appendChild(submit);
|
|
148
|
+
|
|
149
|
+
let settled = false;
|
|
150
|
+
const answerFor = (): string | null => {
|
|
151
|
+
const picked = radios.find((r) => r.checked);
|
|
152
|
+
if (picked !== undefined) {
|
|
153
|
+
return picked.value;
|
|
154
|
+
}
|
|
155
|
+
if (input !== null && (otherRadio === null || otherRadio.checked)) {
|
|
156
|
+
const typed = input.value.trim();
|
|
157
|
+
return typed === "" ? null : typed;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
};
|
|
161
|
+
const refresh = (): void => {
|
|
162
|
+
if (input !== null && otherRadio !== null) {
|
|
163
|
+
input.disabled = !otherRadio.checked;
|
|
164
|
+
}
|
|
165
|
+
submit.disabled = answerFor() === null;
|
|
166
|
+
};
|
|
167
|
+
const close = (answer: string): void => {
|
|
168
|
+
if (settled) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
settled = true;
|
|
172
|
+
submit.disabled = true;
|
|
173
|
+
for (const radio of radios) {
|
|
174
|
+
radio.disabled = true;
|
|
175
|
+
}
|
|
176
|
+
if (otherRadio !== null) {
|
|
177
|
+
otherRadio.disabled = true;
|
|
178
|
+
}
|
|
179
|
+
if (input !== null) {
|
|
180
|
+
input.disabled = true;
|
|
181
|
+
}
|
|
182
|
+
card.setAttribute("data-resolved", answer === "" ? "cancelled" : "answered");
|
|
183
|
+
resolve(answer);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
for (const radio of [...radios, ...(otherRadio !== null ? [otherRadio] : [])]) {
|
|
187
|
+
radio.addEventListener("change", refresh);
|
|
188
|
+
}
|
|
189
|
+
input?.addEventListener("input", refresh);
|
|
190
|
+
input?.addEventListener("keydown", (event) => {
|
|
191
|
+
if (event.key === "Enter") {
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
const answer = answerFor();
|
|
194
|
+
if (answer !== null) {
|
|
195
|
+
close(answer);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
submit.addEventListener("click", () => {
|
|
200
|
+
const answer = answerFor();
|
|
201
|
+
if (answer !== null) {
|
|
202
|
+
close(answer);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
options.signal?.addEventListener("abort", () => close(""), { once: true });
|
|
206
|
+
|
|
207
|
+
card.append(body, form, actions);
|
|
208
|
+
host.appendChild(card);
|
|
209
|
+
if (options.signal?.aborted === true) {
|
|
210
|
+
close("");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
refresh();
|
|
214
|
+
(hasChoices ? radios[0] : input)?.focus();
|
|
215
|
+
});
|
|
216
|
+
}
|
package/src/ui/skills_menu.ts
CHANGED
|
@@ -24,9 +24,11 @@ export class SkillsMenu {
|
|
|
24
24
|
this.#onPick = onPick;
|
|
25
25
|
this.chips = document.createElement("div");
|
|
26
26
|
this.chips.className = "skill-chips";
|
|
27
|
+
this.chips.setAttribute("part", "skill-chips");
|
|
27
28
|
this.chips.hidden = true;
|
|
28
29
|
this.palette = document.createElement("div");
|
|
29
30
|
this.palette.className = "skill-palette";
|
|
31
|
+
this.palette.setAttribute("part", "skill-palette");
|
|
30
32
|
this.palette.setAttribute("role", "listbox");
|
|
31
33
|
this.palette.hidden = true;
|
|
32
34
|
}
|
|
@@ -137,6 +139,7 @@ export class SkillsMenu {
|
|
|
137
139
|
const button = document.createElement("button");
|
|
138
140
|
button.type = "button";
|
|
139
141
|
button.className = "skill-chip";
|
|
142
|
+
button.setAttribute("part", "skill-chip");
|
|
140
143
|
button.textContent = skill.title;
|
|
141
144
|
button.addEventListener("click", () => this.#pick(skill));
|
|
142
145
|
this.chips.appendChild(button);
|
|
@@ -149,17 +152,20 @@ export class SkillsMenu {
|
|
|
149
152
|
const item = document.createElement("button");
|
|
150
153
|
item.type = "button";
|
|
151
154
|
item.className = "skill-item";
|
|
155
|
+
item.setAttribute("part", "skill-item");
|
|
152
156
|
item.setAttribute("role", "option");
|
|
153
157
|
item.setAttribute("aria-selected", index === this.#activeIndex ? "true" : "false");
|
|
154
158
|
|
|
155
159
|
const title = document.createElement("span");
|
|
156
160
|
title.className = "skill-item-title";
|
|
161
|
+
title.setAttribute("part", "skill-item-title");
|
|
157
162
|
title.textContent = skill.title;
|
|
158
163
|
item.appendChild(title);
|
|
159
164
|
|
|
160
165
|
if (skill.description !== undefined) {
|
|
161
166
|
const desc = document.createElement("span");
|
|
162
167
|
desc.className = "skill-item-desc";
|
|
168
|
+
desc.setAttribute("part", "skill-item-desc");
|
|
163
169
|
desc.textContent = skill.description;
|
|
164
170
|
item.appendChild(desc);
|
|
165
171
|
}
|