@artooi/ag-ui-web-component 0.10.0 → 0.12.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 +78 -1
- package/README.md +144 -7
- package/dist/ag-ui-web-component.bundle.js +237 -40
- 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/core/run_index.d.ts +50 -0
- package/dist/core/run_index.d.ts.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +942 -101
- 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/checkpoint_menu.d.ts +32 -0
- package/dist/ui/checkpoint_menu.d.ts.map +1 -0
- 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 +26 -0
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/core/ag_ui_chat.ts +263 -4
- package/src/core/agui_client.ts +86 -11
- package/src/core/run_index.ts +91 -0
- package/src/index.ts +16 -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/checkpoint_menu.ts +153 -0
- package/src/ui/question_card.ts +216 -0
- package/src/ui/skills_menu.ts +6 -0
- package/src/ui/styles.ts +197 -0
- package/src/ui/thoughts_block.ts +1 -0
- package/src/ui/thread_drawer.ts +11 -0
- package/src/ui/ui_strings.ts +45 -0
- package/src/version.ts +1 -1
|
@@ -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,153 @@
|
|
|
1
|
+
import type { RunRow } from "../core/run_index.js";
|
|
2
|
+
import { relativeTime } from "./relative_time.js";
|
|
3
|
+
import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
|
|
4
|
+
|
|
5
|
+
/** How the host continues a picked run. */
|
|
6
|
+
export type CheckpointVerb = "resume" | "fork";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The checkpoint panel: continuable runs, each offering **resume** or **fork**.
|
|
10
|
+
*
|
|
11
|
+
* A separate surface from the thread drawer on purpose — they are different
|
|
12
|
+
* axes. A thread is a conversation you switch *to*; a checkpoint is a run you
|
|
13
|
+
* continue *from*, and one thread can hold many. Folding them into one list
|
|
14
|
+
* would make "resume" look like "open", which it isn't: resuming starts a new
|
|
15
|
+
* run seeded from a snapshot.
|
|
16
|
+
*
|
|
17
|
+
* Only rows the server marked `continuable` are worth offering, so the host
|
|
18
|
+
* feeds those; a run with no snapshot would resume from nothing. Pure DOM in
|
|
19
|
+
* the spirit of {@link SkillsMenu} — the host appends {@link element}, toggles
|
|
20
|
+
* it, feeds rows via {@link setRuns}, and acts on {@link onPick}.
|
|
21
|
+
*/
|
|
22
|
+
export class CheckpointMenu {
|
|
23
|
+
/** The panel root. Append to the chat shell; hidden until opened. */
|
|
24
|
+
readonly element: HTMLDivElement;
|
|
25
|
+
|
|
26
|
+
readonly #onPick: (runId: string, verb: CheckpointVerb) => void;
|
|
27
|
+
readonly #list: HTMLDivElement;
|
|
28
|
+
readonly #heading: HTMLSpanElement;
|
|
29
|
+
#strings: UiStrings;
|
|
30
|
+
#runs: readonly RunRow[] = [];
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
onPick: (runId: string, verb: CheckpointVerb) => void,
|
|
34
|
+
strings: UiStrings = DEFAULT_UI_STRINGS,
|
|
35
|
+
) {
|
|
36
|
+
this.#onPick = onPick;
|
|
37
|
+
this.#strings = strings;
|
|
38
|
+
|
|
39
|
+
this.element = document.createElement("div");
|
|
40
|
+
this.element.className = "checkpoints";
|
|
41
|
+
this.element.setAttribute("part", "checkpoints");
|
|
42
|
+
this.element.setAttribute("role", "dialog");
|
|
43
|
+
this.element.setAttribute("aria-label", strings.checkpoints);
|
|
44
|
+
this.element.hidden = true;
|
|
45
|
+
|
|
46
|
+
const header = document.createElement("div");
|
|
47
|
+
header.className = "checkpoints-header";
|
|
48
|
+
header.setAttribute("part", "checkpoints-header");
|
|
49
|
+
this.#heading = document.createElement("span");
|
|
50
|
+
this.#heading.className = "checkpoints-title";
|
|
51
|
+
this.#heading.textContent = strings.checkpoints;
|
|
52
|
+
header.append(this.#heading);
|
|
53
|
+
|
|
54
|
+
this.#list = document.createElement("div");
|
|
55
|
+
this.#list.className = "checkpoints-list";
|
|
56
|
+
this.#list.setAttribute("part", "checkpoints-list");
|
|
57
|
+
|
|
58
|
+
this.element.append(header, this.#list);
|
|
59
|
+
this.element.addEventListener("keydown", (event) => {
|
|
60
|
+
if (event.key === "Escape") {
|
|
61
|
+
event.stopPropagation();
|
|
62
|
+
this.close();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Replace the rows. The host passes only `continuable` runs. */
|
|
68
|
+
setRuns(runs: readonly RunRow[]): void {
|
|
69
|
+
this.#runs = runs;
|
|
70
|
+
this.#render();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Re-localize a panel built before the host's strings resolved. */
|
|
74
|
+
setStrings(strings: UiStrings): void {
|
|
75
|
+
this.#strings = strings;
|
|
76
|
+
this.element.setAttribute("aria-label", strings.checkpoints);
|
|
77
|
+
this.#heading.textContent = strings.checkpoints;
|
|
78
|
+
this.#render();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
open(): void {
|
|
82
|
+
this.element.hidden = false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
close(): void {
|
|
86
|
+
this.element.hidden = true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get open_(): boolean {
|
|
90
|
+
return !this.element.hidden;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#render(): void {
|
|
94
|
+
this.#list.replaceChildren();
|
|
95
|
+
if (this.#runs.length === 0) {
|
|
96
|
+
const empty = document.createElement("div");
|
|
97
|
+
empty.className = "checkpoints-empty";
|
|
98
|
+
empty.setAttribute("part", "checkpoints-empty");
|
|
99
|
+
empty.textContent = this.#strings.noCheckpoints;
|
|
100
|
+
this.#list.append(empty);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
for (const run of this.#runs) {
|
|
104
|
+
this.#list.append(this.#row(run));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#row(run: RunRow): HTMLDivElement {
|
|
109
|
+
const row = document.createElement("div");
|
|
110
|
+
row.className = "checkpoint-row";
|
|
111
|
+
row.setAttribute("part", "checkpoint-row");
|
|
112
|
+
|
|
113
|
+
const label = document.createElement("span");
|
|
114
|
+
label.className = "checkpoint-label";
|
|
115
|
+
// A run id is opaque to a person, so the time is the identifying detail;
|
|
116
|
+
// the id rides `title` for anyone who needs to correlate with server logs.
|
|
117
|
+
label.textContent =
|
|
118
|
+
run.started_at === null
|
|
119
|
+
? run.run_id
|
|
120
|
+
: relativeTime(Date.parse(run.started_at), Date.now(), this.#strings);
|
|
121
|
+
label.title = run.run_id;
|
|
122
|
+
row.append(label);
|
|
123
|
+
|
|
124
|
+
if (run.parent_run_id !== null) {
|
|
125
|
+
// Lineage, so a branch doesn't read as a duplicate of its parent.
|
|
126
|
+
const branch = document.createElement("span");
|
|
127
|
+
branch.className = "checkpoint-branch";
|
|
128
|
+
branch.setAttribute("part", "checkpoint-branch");
|
|
129
|
+
branch.textContent = this.#strings.forkedRun;
|
|
130
|
+
branch.title = run.parent_run_id;
|
|
131
|
+
row.append(branch);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
row.append(
|
|
135
|
+
this.#action(run.run_id, "resume", this.#strings.resumeRun),
|
|
136
|
+
this.#action(run.run_id, "fork", this.#strings.forkRun),
|
|
137
|
+
);
|
|
138
|
+
return row;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#action(runId: string, verb: CheckpointVerb, label: string): HTMLButtonElement {
|
|
142
|
+
const button = document.createElement("button");
|
|
143
|
+
button.type = "button";
|
|
144
|
+
button.className = `checkpoint-action checkpoint-${verb}`;
|
|
145
|
+
button.setAttribute("part", `checkpoint-action checkpoint-${verb}`);
|
|
146
|
+
button.textContent = label;
|
|
147
|
+
button.addEventListener("click", () => {
|
|
148
|
+
this.close();
|
|
149
|
+
this.#onPick(runId, verb);
|
|
150
|
+
});
|
|
151
|
+
return button;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -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
|
}
|