@artooi/ag-ui-web-component 0.1.0 → 0.2.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 +102 -2
- package/README.md +235 -47
- package/dist/ag-ui-web-component.bundle.js +451 -46
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +34 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/{ag_ui_chat.d.ts → core/ag_ui_chat.d.ts} +63 -6
- package/dist/core/ag_ui_chat.d.ts.map +1 -0
- package/dist/core/agui_client.d.ts.map +1 -0
- package/dist/core/conversation_store.d.ts.map +1 -0
- package/dist/core/create_http_agent.d.ts.map +1 -0
- package/dist/core/define_ag_ui_chat.d.ts.map +1 -0
- package/dist/dom/animations.d.ts +71 -0
- package/dist/dom/animations.d.ts.map +1 -0
- package/dist/{dom_driver.d.ts → dom/dom_driver.d.ts} +7 -1
- package/dist/dom/dom_driver.d.ts.map +1 -0
- package/dist/dom/native_setter.d.ts +5 -0
- package/dist/dom/native_setter.d.ts.map +1 -0
- package/dist/index.d.ts +19 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3886 -388
- package/dist/index.js.map +4 -4
- package/dist/skills/fill_template.d.ts +17 -0
- package/dist/skills/fill_template.d.ts.map +1 -0
- package/dist/skills/parse_skills.d.ts +8 -0
- package/dist/skills/parse_skills.d.ts.map +1 -0
- package/dist/skills/skill.d.ts +24 -0
- package/dist/skills/skill.d.ts.map +1 -0
- package/dist/{client_tool_registry.d.ts → tools/client_tool_registry.d.ts} +6 -1
- package/dist/tools/client_tool_registry.d.ts.map +1 -0
- package/dist/tools/is_destructive.d.ts.map +1 -0
- package/dist/tools/is_navigates.d.ts.map +1 -0
- package/dist/tools/page_map.d.ts.map +1 -0
- package/dist/tools/route_map.d.ts +40 -0
- package/dist/tools/route_map.d.ts.map +1 -0
- package/dist/tools/state_hook.d.ts.map +1 -0
- package/dist/ui/confirmation_card.d.ts +22 -0
- package/dist/ui/confirmation_card.d.ts.map +1 -0
- package/dist/ui/render_markdown.d.ts +13 -0
- package/dist/ui/render_markdown.d.ts.map +1 -0
- package/dist/ui/reveal_words.d.ts +8 -0
- package/dist/ui/reveal_words.d.ts.map +1 -0
- package/dist/ui/skills_menu.d.ts +35 -0
- package/dist/ui/skills_menu.d.ts.map +1 -0
- package/dist/ui/styles.d.ts +2 -0
- package/dist/ui/styles.d.ts.map +1 -0
- package/dist/ui/tool_call_card.d.ts +35 -0
- package/dist/ui/tool_call_card.d.ts.map +1 -0
- package/package.json +5 -3
- package/src/constants.ts +38 -0
- package/src/core/ag_ui_chat.ts +786 -0
- package/src/{agui_client.ts → core/agui_client.ts} +1 -1
- package/src/{define_ag_ui_chat.ts → core/define_ag_ui_chat.ts} +1 -1
- package/src/dom/animations.ts +201 -0
- package/src/{dom_driver.ts → dom/dom_driver.ts} +35 -2
- package/src/dom/native_setter.ts +42 -0
- package/src/index.ts +61 -32
- package/src/skills/fill_template.ts +32 -0
- package/src/skills/parse_skills.ts +26 -0
- package/src/skills/skill.ts +23 -0
- package/src/{client_tool_registry.ts → tools/client_tool_registry.ts} +6 -4
- package/src/{is_destructive.ts → tools/is_destructive.ts} +1 -1
- package/src/{is_navigates.ts → tools/is_navigates.ts} +1 -1
- package/src/tools/route_map.ts +140 -0
- package/src/{state_hook.ts → tools/state_hook.ts} +1 -1
- package/src/ui/confirmation_card.ts +71 -0
- package/src/ui/render_markdown.ts +68 -0
- package/src/ui/reveal_words.ts +41 -0
- package/src/ui/skills_menu.ts +171 -0
- package/src/ui/styles.ts +631 -0
- package/src/{tool_call_card.ts → ui/tool_call_card.ts} +50 -19
- package/src/version.ts +1 -1
- package/dist/ag_ui_chat.d.ts.map +0 -1
- package/dist/agui_client.d.ts.map +0 -1
- package/dist/animations.d.ts +0 -33
- package/dist/animations.d.ts.map +0 -1
- package/dist/client_tool_registry.d.ts.map +0 -1
- package/dist/confirmation_modal.d.ts +0 -14
- package/dist/confirmation_modal.d.ts.map +0 -1
- package/dist/conversation_store.d.ts.map +0 -1
- package/dist/create_http_agent.d.ts.map +0 -1
- package/dist/define_ag_ui_chat.d.ts.map +0 -1
- package/dist/dom_driver.d.ts.map +0 -1
- package/dist/is_destructive.d.ts.map +0 -1
- package/dist/is_navigates.d.ts.map +0 -1
- package/dist/page_map.d.ts.map +0 -1
- package/dist/route_map.d.ts +0 -27
- package/dist/route_map.d.ts.map +0 -1
- package/dist/state_hook.d.ts.map +0 -1
- package/dist/styles.d.ts +0 -2
- package/dist/styles.d.ts.map +0 -1
- package/dist/tool_call_card.d.ts +0 -29
- package/dist/tool_call_card.d.ts.map +0 -1
- package/src/ag_ui_chat.ts +0 -411
- package/src/animations.ts +0 -86
- package/src/confirmation_modal.ts +0 -69
- package/src/route_map.ts +0 -83
- package/src/styles.ts +0 -296
- /package/dist/{agui_client.d.ts → core/agui_client.d.ts} +0 -0
- /package/dist/{conversation_store.d.ts → core/conversation_store.d.ts} +0 -0
- /package/dist/{create_http_agent.d.ts → core/create_http_agent.d.ts} +0 -0
- /package/dist/{define_ag_ui_chat.d.ts → core/define_ag_ui_chat.d.ts} +0 -0
- /package/dist/{is_destructive.d.ts → tools/is_destructive.d.ts} +0 -0
- /package/dist/{is_navigates.d.ts → tools/is_navigates.d.ts} +0 -0
- /package/dist/{page_map.d.ts → tools/page_map.d.ts} +0 -0
- /package/dist/{state_hook.d.ts → tools/state_hook.d.ts} +0 -0
- /package/src/{conversation_store.ts → core/conversation_store.ts} +0 -0
- /package/src/{create_http_agent.ts → core/create_http_agent.ts} +0 -0
- /package/src/{page_map.ts → tools/page_map.ts} +0 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { X_NAVIGATES_KEY } from "../constants.js";
|
|
2
|
+
import type { ClientTool } from "./client_tool_registry.js";
|
|
3
|
+
|
|
4
|
+
/** A single navigable route the host declares for the agent. */
|
|
5
|
+
export interface Route {
|
|
6
|
+
/** Stable id the agent uses with `navigate_to_route`. */
|
|
7
|
+
readonly id: string;
|
|
8
|
+
/**
|
|
9
|
+
* The URL path to navigate to. May contain `:name` placeholders for dynamic
|
|
10
|
+
* segments (e.g. `/admin/shop/book/:pk/change/`); the agent fills them via
|
|
11
|
+
* the `navigate_to_route` `params` argument. Real apps are mostly
|
|
12
|
+
* parameterised — few pages have a truly static path.
|
|
13
|
+
*/
|
|
14
|
+
readonly path: string;
|
|
15
|
+
/** Human label shown to the agent. */
|
|
16
|
+
readonly title?: string;
|
|
17
|
+
/** Optional grouping (e.g. an app or section). */
|
|
18
|
+
readonly group?: string;
|
|
19
|
+
/** Optional longer description of when to use the route. */
|
|
20
|
+
readonly description?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The host-declared catalog of navigable routes. */
|
|
24
|
+
export type RouteMap = readonly Route[];
|
|
25
|
+
|
|
26
|
+
/** A route enriched with its derived dynamic-segment parameter names. */
|
|
27
|
+
export interface RouteWithParams extends Route {
|
|
28
|
+
/** Names of the `:name` placeholders in {@link Route.path} (may be empty). */
|
|
29
|
+
readonly pathParams: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const PATH_PARAM_RE = /:([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
33
|
+
|
|
34
|
+
/** The `:name` path-parameter names declared in a path template, in order. */
|
|
35
|
+
function pathParamNames(path: string): string[] {
|
|
36
|
+
const names: string[] = [];
|
|
37
|
+
for (const match of path.matchAll(PATH_PARAM_RE)) {
|
|
38
|
+
const name = match[1];
|
|
39
|
+
if (name !== undefined) {
|
|
40
|
+
names.push(name);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return names;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Substitute `:name` placeholders in ``path`` from ``params``.
|
|
48
|
+
*
|
|
49
|
+
* Returns the concrete path plus the params *not* consumed by a placeholder, so
|
|
50
|
+
* the caller can append those as a query string. Throws (referencing
|
|
51
|
+
* ``routeId``) when a declared path param is missing or empty — a half-filled
|
|
52
|
+
* path must never be navigated to.
|
|
53
|
+
*/
|
|
54
|
+
function fillPath(
|
|
55
|
+
routeId: string,
|
|
56
|
+
path: string,
|
|
57
|
+
params: Record<string, unknown>,
|
|
58
|
+
): { path: string; leftover: Record<string, unknown> } {
|
|
59
|
+
const leftover: Record<string, unknown> = { ...params };
|
|
60
|
+
const filled = path.replace(PATH_PARAM_RE, (_match, name: string) => {
|
|
61
|
+
const value = params[name];
|
|
62
|
+
if (value === undefined || value === null || String(value) === "") {
|
|
63
|
+
throw new Error(`route "${routeId}" requires path param "${name}"`);
|
|
64
|
+
}
|
|
65
|
+
delete leftover[name];
|
|
66
|
+
return encodeURIComponent(String(value));
|
|
67
|
+
});
|
|
68
|
+
return { path: filled, leftover };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Append `params` to `path` as a query string (no-op when empty). */
|
|
72
|
+
function withQuery(path: string, params: Record<string, unknown>): string {
|
|
73
|
+
const usp = new URLSearchParams();
|
|
74
|
+
for (const [key, value] of Object.entries(params)) {
|
|
75
|
+
usp.set(key, String(value));
|
|
76
|
+
}
|
|
77
|
+
const query = usp.toString();
|
|
78
|
+
return query === "" ? path : `${path}?${query}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The built-in `route.*` tools, bound to live getters so a host can set
|
|
83
|
+
* `routeMap` / `navigate` before or after mount.
|
|
84
|
+
*
|
|
85
|
+
* `list_routes` is read-only and advertises each route's dynamic
|
|
86
|
+
* `pathParams` so the model knows what to supply. `navigate_to_route` is marked
|
|
87
|
+
* `x-navigates` so an MPA reload checkpoints + resumes; it substitutes path
|
|
88
|
+
* params into the template and appends any remaining params as a query string.
|
|
89
|
+
* When the host supplies a `navigate(path)` callback (an SPA), the element
|
|
90
|
+
* routes client-side instead and the run loop simply continues — see
|
|
91
|
+
* `AgUiChat`'s execute path.
|
|
92
|
+
*/
|
|
93
|
+
export function createRouteTools(
|
|
94
|
+
getRouteMap: () => RouteMap,
|
|
95
|
+
getNavigate: () => ((path: string) => void) | null,
|
|
96
|
+
): ClientTool[] {
|
|
97
|
+
return [
|
|
98
|
+
{
|
|
99
|
+
name: "list_routes",
|
|
100
|
+
description:
|
|
101
|
+
"List the routes the app can navigate to. Each route's `pathParams` " +
|
|
102
|
+
"names the dynamic segments to pass as `params` to `navigate_to_route`.",
|
|
103
|
+
parameters: { type: "object", properties: {}, required: [] },
|
|
104
|
+
handler: (): RouteWithParams[] =>
|
|
105
|
+
getRouteMap().map((route) => ({ ...route, pathParams: pathParamNames(route.path) })),
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "navigate_to_route",
|
|
109
|
+
description:
|
|
110
|
+
"Navigate to one of the app's routes by its id, filling any dynamic " +
|
|
111
|
+
"`:name` path segments (and extra query params) from `params`.",
|
|
112
|
+
parameters: {
|
|
113
|
+
type: "object",
|
|
114
|
+
properties: {
|
|
115
|
+
route_id: { type: "string" },
|
|
116
|
+
params: { type: "object" },
|
|
117
|
+
},
|
|
118
|
+
required: ["route_id"],
|
|
119
|
+
[X_NAVIGATES_KEY]: true,
|
|
120
|
+
},
|
|
121
|
+
handler: (args) => {
|
|
122
|
+
const routeId = args["route_id"];
|
|
123
|
+
const route = getRouteMap().find((r) => r.id === routeId);
|
|
124
|
+
if (route === undefined) {
|
|
125
|
+
throw new Error(`unknown route "${String(routeId)}"`);
|
|
126
|
+
}
|
|
127
|
+
const provided = (args["params"] as Record<string, unknown> | undefined) ?? {};
|
|
128
|
+
const { path: filledPath, leftover } = fillPath(route.id, route.path, provided);
|
|
129
|
+
const path = withQuery(filledPath, leftover);
|
|
130
|
+
const navigate = getNavigate();
|
|
131
|
+
if (navigate !== null) {
|
|
132
|
+
navigate(path);
|
|
133
|
+
} else {
|
|
134
|
+
window.location.assign(path);
|
|
135
|
+
}
|
|
136
|
+
return { navigated: true, path };
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** What the inline confirmation card displays. */
|
|
2
|
+
export interface ConfirmationRequest {
|
|
3
|
+
toolName: string;
|
|
4
|
+
args: Record<string, unknown>;
|
|
5
|
+
/**
|
|
6
|
+
* Human-readable prompt (from the tool's `x-confirm` metadata), e.g.
|
|
7
|
+
* "Activate this project?". Falls back to a generic `Run "<tool>"?`.
|
|
8
|
+
*/
|
|
9
|
+
message?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Build a labelled action button. */
|
|
13
|
+
function actionButton(modifier: string, label: string): HTMLButtonElement {
|
|
14
|
+
const button = document.createElement("button");
|
|
15
|
+
button.type = "button";
|
|
16
|
+
button.className = `confirm-btn confirm-btn--${modifier}`;
|
|
17
|
+
button.textContent = label;
|
|
18
|
+
return button;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Append an inline confirmation card to ``host`` (the chat message list) and
|
|
23
|
+
* resolve when the user decides.
|
|
24
|
+
*
|
|
25
|
+
* Unlike a modal overlay, the card lives in the transcript right where the
|
|
26
|
+
* action is — it reads naturally after the assistant's explanation and never
|
|
27
|
+
* steals focus from the page. Resolves ``true`` on confirm, ``false`` on
|
|
28
|
+
* cancel. The card stays in the transcript as a resolved record (buttons
|
|
29
|
+
* disabled, `data-resolved` set) rather than vanishing.
|
|
30
|
+
*/
|
|
31
|
+
export function requestConfirmation(
|
|
32
|
+
host: Node & ParentNode,
|
|
33
|
+
request: ConfirmationRequest,
|
|
34
|
+
): Promise<boolean> {
|
|
35
|
+
return new Promise<boolean>((resolve) => {
|
|
36
|
+
const card = document.createElement("div");
|
|
37
|
+
card.className = "confirm";
|
|
38
|
+
card.setAttribute("data-tool-name", request.toolName);
|
|
39
|
+
card.setAttribute("role", "group");
|
|
40
|
+
card.setAttribute("aria-label", "Confirm action");
|
|
41
|
+
|
|
42
|
+
const body = document.createElement("div");
|
|
43
|
+
body.className = "confirm-body";
|
|
44
|
+
body.textContent = request.message ?? `Run “${request.toolName}”?`;
|
|
45
|
+
|
|
46
|
+
const args = document.createElement("pre");
|
|
47
|
+
args.className = "confirm-args";
|
|
48
|
+
args.textContent = JSON.stringify(request.args, null, 2);
|
|
49
|
+
|
|
50
|
+
const actions = document.createElement("div");
|
|
51
|
+
actions.className = "confirm-actions";
|
|
52
|
+
|
|
53
|
+
const cancel = actionButton("cancel", "Cancel");
|
|
54
|
+
const confirm = actionButton("confirm", "Confirm");
|
|
55
|
+
|
|
56
|
+
const close = (accepted: boolean): void => {
|
|
57
|
+
cancel.disabled = true;
|
|
58
|
+
confirm.disabled = true;
|
|
59
|
+
card.setAttribute("data-resolved", accepted ? "confirmed" : "declined");
|
|
60
|
+
resolve(accepted);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
cancel.addEventListener("click", () => close(false));
|
|
64
|
+
confirm.addEventListener("click", () => close(true));
|
|
65
|
+
|
|
66
|
+
actions.append(cancel, confirm);
|
|
67
|
+
card.append(body, args, actions);
|
|
68
|
+
host.appendChild(card);
|
|
69
|
+
confirm.focus();
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import DOMPurify from "dompurify";
|
|
2
|
+
import { marked } from "marked";
|
|
3
|
+
|
|
4
|
+
// GitHub-flavoured markdown with single-newline line breaks (chat-like).
|
|
5
|
+
marked.setOptions({ gfm: true, breaks: true });
|
|
6
|
+
|
|
7
|
+
// Conservative allowlist for assistant chat content: inline emphasis, code,
|
|
8
|
+
// lists, quotes, headings, links, tables, and images. Deliberately excludes
|
|
9
|
+
// `iframe`, `style`, and any scripting — rendering untrusted model/tool output
|
|
10
|
+
// as HTML is an XSS surface, so the sanitiser is the load-bearing safety net.
|
|
11
|
+
const ALLOWED_TAGS = [
|
|
12
|
+
"a",
|
|
13
|
+
"p",
|
|
14
|
+
"br",
|
|
15
|
+
"strong",
|
|
16
|
+
"em",
|
|
17
|
+
"b",
|
|
18
|
+
"i",
|
|
19
|
+
"u",
|
|
20
|
+
"s",
|
|
21
|
+
"del",
|
|
22
|
+
"code",
|
|
23
|
+
"pre",
|
|
24
|
+
"ul",
|
|
25
|
+
"ol",
|
|
26
|
+
"li",
|
|
27
|
+
"blockquote",
|
|
28
|
+
"h1",
|
|
29
|
+
"h2",
|
|
30
|
+
"h3",
|
|
31
|
+
"h4",
|
|
32
|
+
"h5",
|
|
33
|
+
"h6",
|
|
34
|
+
"hr",
|
|
35
|
+
"span",
|
|
36
|
+
"table",
|
|
37
|
+
"thead",
|
|
38
|
+
"tbody",
|
|
39
|
+
"tr",
|
|
40
|
+
"th",
|
|
41
|
+
"td",
|
|
42
|
+
"img",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const ALLOWED_ATTR = ["href", "title", "class", "src", "alt", "width", "height"];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Render markdown (and any embedded raw HTML) to a sanitised HTML string.
|
|
49
|
+
*
|
|
50
|
+
* Both markdown syntax and literal HTML flow through one path: `marked` emits
|
|
51
|
+
* HTML, then DOMPurify strips everything outside {@link ALLOWED_TAGS} /
|
|
52
|
+
* {@link ALLOWED_ATTR} (scripts, event handlers, `javascript:` URLs, etc.).
|
|
53
|
+
* Links are hardened with `target="_blank"` + `rel="noopener noreferrer"`.
|
|
54
|
+
*
|
|
55
|
+
* The result is trimmed so a single-paragraph message round-trips to clean
|
|
56
|
+
* `textContent` (no trailing newline from the wrapping `<p>`).
|
|
57
|
+
*/
|
|
58
|
+
export function renderMarkdown(text: string): string {
|
|
59
|
+
const rendered = marked.parse(text, { async: false });
|
|
60
|
+
const clean = DOMPurify.sanitize(rendered, { ALLOWED_TAGS, ALLOWED_ATTR });
|
|
61
|
+
const template = document.createElement("template");
|
|
62
|
+
template.innerHTML = clean;
|
|
63
|
+
for (const anchor of template.content.querySelectorAll("a[href]")) {
|
|
64
|
+
anchor.setAttribute("target", "_blank");
|
|
65
|
+
anchor.setAttribute("rel", "noopener noreferrer");
|
|
66
|
+
}
|
|
67
|
+
return template.innerHTML.trim();
|
|
68
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Collect the text nodes under ``root`` with their parents (depth-first). */
|
|
2
|
+
function collectTextNodes(root: Node, out: { node: Text; parent: Node }[]): void {
|
|
3
|
+
for (const child of Array.from(root.childNodes)) {
|
|
4
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
5
|
+
out.push({ node: child as Text, parent: root });
|
|
6
|
+
} else {
|
|
7
|
+
collectTextNodes(child, out);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Wrap each word under ``root`` in `<span class="word" style="--ag-ui-word-index: N">`
|
|
14
|
+
* so CSS can reveal them one-by-one (staggered by the index). Whitespace is
|
|
15
|
+
* preserved as plain text. Used for the `word` text-animation mode on a
|
|
16
|
+
* completed assistant message; pure DOM, idempotent enough to re-run.
|
|
17
|
+
*/
|
|
18
|
+
export function wrapWords(root: HTMLElement): void {
|
|
19
|
+
const texts: { node: Text; parent: Node }[] = [];
|
|
20
|
+
collectTextNodes(root, texts);
|
|
21
|
+
let index = 0;
|
|
22
|
+
for (const { node, parent } of texts) {
|
|
23
|
+
const fragment = document.createDocumentFragment();
|
|
24
|
+
for (const part of node.data.split(/(\s+)/)) {
|
|
25
|
+
if (part === "") {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (/\s/.test(part)) {
|
|
29
|
+
fragment.appendChild(document.createTextNode(part));
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const span = document.createElement("span");
|
|
33
|
+
span.className = "word";
|
|
34
|
+
span.style.setProperty("--ag-ui-word-index", String(index));
|
|
35
|
+
span.textContent = part;
|
|
36
|
+
fragment.appendChild(span);
|
|
37
|
+
index += 1;
|
|
38
|
+
}
|
|
39
|
+
parent.replaceChild(fragment, node);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { Skill } from "../skills/skill.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two skill surfaces over a single catalog: a **chips** row (the
|
|
5
|
+
* `chip: true` subset) and a **`/`-command palette** (all skills, filtered).
|
|
6
|
+
* Both are opt-in. Pure DOM; the host ({@link AgUiChat}) appends
|
|
7
|
+
* {@link chips} + {@link palette}, feeds input via {@link onInput} /
|
|
8
|
+
* {@link onKeydown}, and acts on the {@link onPick} callback.
|
|
9
|
+
*/
|
|
10
|
+
export class SkillsMenu {
|
|
11
|
+
/** Chips row — append above the input. Hidden unless chips are enabled and present. */
|
|
12
|
+
readonly chips: HTMLDivElement;
|
|
13
|
+
/** The `/`-command dropdown — append in the input area. Hidden until opened. */
|
|
14
|
+
readonly palette: HTMLDivElement;
|
|
15
|
+
|
|
16
|
+
readonly #onPick: (skill: Skill) => void;
|
|
17
|
+
#skills: readonly Skill[] = [];
|
|
18
|
+
#chipsEnabled = false;
|
|
19
|
+
#slashEnabled = false;
|
|
20
|
+
#filtered: Skill[] = [];
|
|
21
|
+
#activeIndex = 0;
|
|
22
|
+
|
|
23
|
+
constructor(onPick: (skill: Skill) => void) {
|
|
24
|
+
this.#onPick = onPick;
|
|
25
|
+
this.chips = document.createElement("div");
|
|
26
|
+
this.chips.className = "skill-chips";
|
|
27
|
+
this.chips.hidden = true;
|
|
28
|
+
this.palette = document.createElement("div");
|
|
29
|
+
this.palette.className = "skill-palette";
|
|
30
|
+
this.palette.setAttribute("role", "listbox");
|
|
31
|
+
this.palette.hidden = true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Replace the catalog (both surfaces re-derive from it). */
|
|
35
|
+
setSkills(skills: readonly Skill[]): void {
|
|
36
|
+
this.#skills = skills;
|
|
37
|
+
this.#renderChips();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
enableChips(enabled: boolean): void {
|
|
41
|
+
this.#chipsEnabled = enabled;
|
|
42
|
+
this.#renderChips();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
enableSlash(enabled: boolean): void {
|
|
46
|
+
this.#slashEnabled = enabled;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether the palette is currently open. */
|
|
50
|
+
isOpen(): boolean {
|
|
51
|
+
return !this.palette.hidden;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* React to an input value change: open + filter the palette when slash mode
|
|
56
|
+
* is on and the value starts with `/`, otherwise close it.
|
|
57
|
+
*/
|
|
58
|
+
onInput(value: string): void {
|
|
59
|
+
if (this.#slashEnabled && value.startsWith("/")) {
|
|
60
|
+
this.#open(value.slice(1));
|
|
61
|
+
} else {
|
|
62
|
+
this.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Handle a keydown while the palette is open. Returns ``true`` when consumed
|
|
68
|
+
* (the host should `preventDefault` and skip its own handling).
|
|
69
|
+
*/
|
|
70
|
+
onKeydown(event: KeyboardEvent): boolean {
|
|
71
|
+
if (!this.isOpen()) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (event.key === "ArrowDown") {
|
|
75
|
+
this.#move(1);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
if (event.key === "ArrowUp") {
|
|
79
|
+
this.#move(-1);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
if (event.key === "Escape") {
|
|
83
|
+
this.close();
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
if (event.key === "Enter") {
|
|
87
|
+
// `slice(i, i+1)` yields a 0-or-1 element array; forEach gives a
|
|
88
|
+
// definite Skill (no indexed-access `| undefined`).
|
|
89
|
+
this.#filtered.slice(this.#activeIndex, this.#activeIndex + 1).forEach((skill) => {
|
|
90
|
+
this.#pick(skill);
|
|
91
|
+
});
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Close the palette. */
|
|
98
|
+
close(): void {
|
|
99
|
+
this.palette.hidden = true;
|
|
100
|
+
this.palette.replaceChildren();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#open(query: string): void {
|
|
104
|
+
const needle = query.trim().toLowerCase();
|
|
105
|
+
const matches = this.#skills.filter(
|
|
106
|
+
(skill) =>
|
|
107
|
+
skill.name.toLowerCase().includes(needle) || skill.title.toLowerCase().includes(needle),
|
|
108
|
+
);
|
|
109
|
+
if (matches.length === 0) {
|
|
110
|
+
this.close();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
this.#filtered = matches;
|
|
114
|
+
this.#activeIndex = 0;
|
|
115
|
+
this.#renderPalette();
|
|
116
|
+
this.palette.hidden = false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#move(delta: number): void {
|
|
120
|
+
const count = this.#filtered.length;
|
|
121
|
+
this.#activeIndex = (this.#activeIndex + delta + count) % count;
|
|
122
|
+
this.#renderPalette();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#pick(skill: Skill): void {
|
|
126
|
+
this.close();
|
|
127
|
+
this.#onPick(skill);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#renderChips(): void {
|
|
131
|
+
this.chips.replaceChildren();
|
|
132
|
+
const chipSkills = this.#chipsEnabled
|
|
133
|
+
? this.#skills.filter((skill) => skill.chip === true)
|
|
134
|
+
: [];
|
|
135
|
+
this.chips.hidden = chipSkills.length === 0;
|
|
136
|
+
for (const skill of chipSkills) {
|
|
137
|
+
const button = document.createElement("button");
|
|
138
|
+
button.type = "button";
|
|
139
|
+
button.className = "skill-chip";
|
|
140
|
+
button.textContent = skill.title;
|
|
141
|
+
button.addEventListener("click", () => this.#pick(skill));
|
|
142
|
+
this.chips.appendChild(button);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#renderPalette(): void {
|
|
147
|
+
this.palette.replaceChildren();
|
|
148
|
+
this.#filtered.forEach((skill, index) => {
|
|
149
|
+
const item = document.createElement("button");
|
|
150
|
+
item.type = "button";
|
|
151
|
+
item.className = "skill-item";
|
|
152
|
+
item.setAttribute("role", "option");
|
|
153
|
+
item.setAttribute("aria-selected", index === this.#activeIndex ? "true" : "false");
|
|
154
|
+
|
|
155
|
+
const title = document.createElement("span");
|
|
156
|
+
title.className = "skill-item-title";
|
|
157
|
+
title.textContent = skill.title;
|
|
158
|
+
item.appendChild(title);
|
|
159
|
+
|
|
160
|
+
if (skill.description !== undefined) {
|
|
161
|
+
const desc = document.createElement("span");
|
|
162
|
+
desc.className = "skill-item-desc";
|
|
163
|
+
desc.textContent = skill.description;
|
|
164
|
+
item.appendChild(desc);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
item.addEventListener("click", () => this.#pick(skill));
|
|
168
|
+
this.palette.appendChild(item);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|