akanjs 3.0.0-alpha.29 → 3.0.0-alpha.30
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/dictionary/base.dictionary.ts +5 -0
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/store/agent/ScreenReader.ts +108 -7
- package/store/agent/ScreenSettle.ts +45 -0
- package/store/agent/ScreenTarget.ts +129 -0
- package/store/agent/StoreSurfaceSource.ts +140 -14
- package/store/agent/index.ts +2 -0
- package/store/agentic/StToolBuilder.ts +59 -23
- package/store/agentic/attachAgentic.ts +9 -4
- package/store/agentic/formFields.ts +182 -0
- package/store/agentic/index.ts +3 -0
- package/store/agentic/useFieldTool.ts +40 -0
- package/store/agentic/useFormTools.ts +68 -0
- package/store/agentic/useStExpose.ts +2 -2
- package/store/agentic/useStState.ts +3 -3
- package/store/baseSt.ts +1 -1
- package/store/storeInstance.ts +24 -4
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/store/agent/ScreenReader.d.ts +16 -3
- package/types/store/agent/ScreenSettle.d.ts +22 -0
- package/types/store/agent/ScreenTarget.d.ts +33 -0
- package/types/store/agent/StoreSurfaceSource.d.ts +11 -6
- package/types/store/agent/index.d.ts +2 -0
- package/types/store/agentic/StToolBuilder.d.ts +15 -4
- package/types/store/agentic/attachAgentic.d.ts +9 -4
- package/types/store/agentic/formFields.d.ts +44 -0
- package/types/store/agentic/index.d.ts +3 -0
- package/types/store/agentic/useFieldTool.d.ts +10 -0
- package/types/store/agentic/useFormTools.d.ts +14 -0
- package/types/store/agentic/useStExpose.d.ts +1 -1
- package/types/store/agentic/useStState.d.ts +1 -1
- package/types/store/baseSt.d.ts +13 -13
- package/types/ui/Agent/Bubble.d.ts +6 -2
- package/types/ui/Agent/Question.d.ts +13 -0
- package/types/ui/Dialog/Provider.d.ts +3 -1
- package/types/ui/Dialog/context.d.ts +2 -0
- package/types/ui/ScreenNavigator.d.ts +3 -1
- package/types/ui/Signal/Doc.d.ts +2 -6
- package/types/ui/Signal/endpointEntries.d.ts +6 -0
- package/types/ui/Tab/Provider.d.ts +3 -1
- package/types/ui/Tab/context.d.ts +3 -1
- package/types/vendor/use-agentic/AgentProgress.d.ts +27 -0
- package/types/vendor/use-agentic/AgentSession.d.ts +40 -2
- package/types/vendor/use-agentic/index.d.ts +1 -0
- package/types/vendor/use-agentic/useAgentResource.d.ts +7 -2
- package/types/vendor/use-agentic/useAgentState.d.ts +5 -2
- package/types/webkit/index.d.ts +1 -0
- package/types/webkit/usePageTool.d.ts +19 -0
- package/types/webkit/useScreenScope.d.ts +8 -1
- package/ui/Agent/Bubble.tsx +56 -7
- package/ui/Agent/Chat.tsx +21 -6
- package/ui/Agent/Question.tsx +60 -0
- package/ui/Agent/Zone.tsx +5 -1
- package/ui/Data/ListContainer.tsx +139 -23
- package/ui/Data/Pagination.tsx +10 -3
- package/ui/Dialog/Close.tsx +3 -7
- package/ui/Dialog/Provider.tsx +29 -2
- package/ui/Dialog/Trigger.tsx +3 -7
- package/ui/Dialog/context.ts +4 -0
- package/ui/Field.tsx +15 -2
- package/ui/Input.tsx +7 -1
- package/ui/Layout/Sider.tsx +6 -0
- package/ui/Load/Pagination.tsx +15 -8
- package/ui/Load/Units.tsx +16 -4
- package/ui/Load/View.tsx +4 -2
- package/ui/Reference/Collapse.tsx +2 -2
- package/ui/ScreenNavigator.tsx +14 -0
- package/ui/Select.tsx +2 -1
- package/ui/Signal/Doc.tsx +75 -62
- package/ui/Signal/Message.tsx +2 -1
- package/ui/Signal/PubSub.tsx +2 -1
- package/ui/Signal/RestApi.tsx +5 -21
- package/ui/Signal/WebSocket.tsx +12 -5
- package/ui/Signal/endpointEntries.ts +19 -0
- package/ui/Switch.tsx +2 -0
- package/ui/System/SelectLanguage.tsx +23 -15
- package/ui/Tab/Menu.tsx +12 -7
- package/ui/Tab/Provider.tsx +31 -3
- package/ui/Tab/context.ts +5 -2
- package/ui/recipe/buttonRecipe.ts +2 -2
- package/ui/styles.css +27 -0
- package/vendor/use-agentic/AgentProgress.ts +40 -0
- package/vendor/use-agentic/AgentSession.ts +151 -6
- package/vendor/use-agentic/index.ts +1 -0
- package/vendor/use-agentic/useAgentResource.ts +8 -2
- package/vendor/use-agentic/useAgentState.ts +7 -3
- package/webkit/index.ts +1 -0
- package/webkit/usePageTool.tsx +39 -0
- package/webkit/useScreenScope.tsx +15 -4
|
@@ -52,8 +52,13 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
|
|
|
52
52
|
agentIntro: ["Ask about this page or tell the agent what to do.", "이 화면에 대해 묻거나 할 일을 지시해 보세요."],
|
|
53
53
|
agentPlaceholder: ["Message the agent...", "에이전트에게 메시지..."],
|
|
54
54
|
agentClear: ["Clear conversation", "대화 비우기"],
|
|
55
|
+
agentQuestion: ["The agent needs your decision", "에이전트가 결정을 요청합니다"],
|
|
56
|
+
agentAnswer: ["Type your answer...", "답변을 입력하세요..."],
|
|
57
|
+
agentContinue: ["This is taking a while. Keep going?", "시간이 걸리고 있습니다. 계속할까요?"],
|
|
58
|
+
agentKeepGoing: ["Keep going", "계속하기"],
|
|
55
59
|
send: ["Send", "보내기"],
|
|
56
60
|
stop: ["Stop", "중지"],
|
|
61
|
+
skip: ["Skip", "건너뛰기"],
|
|
57
62
|
approve: ["Approve", "승인"],
|
|
58
63
|
decline: ["Decline", "거절"],
|
|
59
64
|
confirmClose: ["Are you sure you want to close the modal?", "모달을 닫으시겠습니까?"],
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -12,6 +12,7 @@ const skipTags = new Set([
|
|
|
12
12
|
"EMBED",
|
|
13
13
|
]);
|
|
14
14
|
const headingLevels = { H1: 1, H2: 2, H3: 3, H4: 4, H5: 5, H6: 6 } as const;
|
|
15
|
+
const headingSelector = "h1, h2, h3, h4, h5, h6";
|
|
15
16
|
const blockTags = new Set([
|
|
16
17
|
"ADDRESS",
|
|
17
18
|
"ARTICLE",
|
|
@@ -46,9 +47,14 @@ const blockTags = new Set([
|
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
49
|
* Serializes what the page is rendering into compact text an agent can answer questions from: headings keep their
|
|
49
|
-
* level, links keep their href, controls keep their value and `data-akan-*` annotation. The
|
|
50
|
-
* marked `data-agent-ui` and skipped, so a turn never re-reads its own transcript, and a
|
|
51
|
-
* read — the screen shows dots, so the DOM holds more than the user sees.
|
|
50
|
+
* level and their anchor, links keep their href, controls keep their value and `data-akan-*` annotation. The
|
|
51
|
+
* agent's own UI is marked `data-agent-ui` and skipped, so a turn never re-reads its own transcript, and a
|
|
52
|
+
* password value is never read — the screen shows dots, so the DOM holds more than the user sees.
|
|
53
|
+
*
|
|
54
|
+
* A heading carries `(#anchor)` whenever it opens a container that has an id or a scope path, because that is the
|
|
55
|
+
* name `readScreen({ section })` and `highlight` take: printing the text without the name leaves an agent
|
|
56
|
+
* guessing at a slug. For the same reason a truncated read ends with the headings below the cut instead of only
|
|
57
|
+
* a character count — otherwise everything past the limit is unreachable, since nothing names it.
|
|
52
58
|
*/
|
|
53
59
|
export class ScreenReader {
|
|
54
60
|
static readonly limit = 8000;
|
|
@@ -60,17 +66,108 @@ export class ScreenReader {
|
|
|
60
66
|
if (title) reader.#lines.push(`Page: ${title}`);
|
|
61
67
|
reader.#walk(root ?? document.body);
|
|
62
68
|
reader.#flush();
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
69
|
+
return reader.#text() || "The page is rendering nothing readable.";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* One heading's own section: the heading, then what follows it until the next heading of the same level or
|
|
74
|
+
* higher. Reads the heading's container when the heading is the only one in it — the shape a docs slide or an
|
|
75
|
+
* `<article>` has — and otherwise the heading's own following siblings.
|
|
76
|
+
*/
|
|
77
|
+
static readFrom(heading: HTMLElement, root?: HTMLElement | null): string {
|
|
78
|
+
if (typeof document === "undefined") return "No rendered document is available.";
|
|
79
|
+
const reader = new ScreenReader();
|
|
80
|
+
const container = ScreenReader.#sectionOf(heading, root);
|
|
81
|
+
if (container) reader.#walk(container);
|
|
82
|
+
else {
|
|
83
|
+
const level = headingLevels[heading.tagName.toUpperCase() as keyof typeof headingLevels] ?? 1;
|
|
84
|
+
reader.#walk(heading);
|
|
85
|
+
let next = heading.nextElementSibling;
|
|
86
|
+
while (next && !ScreenReader.#stops(next, level)) {
|
|
87
|
+
reader.#walk(next);
|
|
88
|
+
next = next.nextElementSibling;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
reader.#flush();
|
|
92
|
+
return reader.#text() || "That section is rendering nothing readable.";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The outermost ancestor that still holds this heading and no other of its level — the section, not the title
|
|
97
|
+
* wrapper inside it. Climbing matters: a docs slide puts its heading two or three divs down, so the innermost
|
|
98
|
+
* match is often the heading and nothing else.
|
|
99
|
+
*/
|
|
100
|
+
static #sectionOf(heading: HTMLElement, root?: HTMLElement | null): HTMLElement | null {
|
|
101
|
+
const level = headingLevels[heading.tagName.toUpperCase() as keyof typeof headingLevels] ?? 1;
|
|
102
|
+
const boundary = root ?? document.body;
|
|
103
|
+
let container: HTMLElement | null = null;
|
|
104
|
+
let parent = heading.parentElement;
|
|
105
|
+
for (let depth = 0; parent && parent !== boundary && depth < 6; depth += 1) {
|
|
106
|
+
const owned = [...parent.querySelectorAll(headingSelector)].filter((found) => ScreenReader.#stops(found, level));
|
|
107
|
+
if (owned.length !== 1) break;
|
|
108
|
+
container = parent;
|
|
109
|
+
parent = parent.parentElement;
|
|
110
|
+
}
|
|
111
|
+
return container;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The anchor a heading is addressable by: its own id, or the id or scope path of the container it opens. */
|
|
115
|
+
static anchorOf(heading: HTMLElement): string {
|
|
116
|
+
if (heading.id) return heading.id;
|
|
117
|
+
let parent = heading.parentElement;
|
|
118
|
+
for (let depth = 0; parent && depth < 4; depth += 1) {
|
|
119
|
+
const name = parent.getAttribute("data-agent-scope") ?? parent.getAttribute("data-agent-zone") ?? parent.id;
|
|
120
|
+
if (name && parent.querySelector(headingSelector) === heading) return name;
|
|
121
|
+
parent = parent.parentElement;
|
|
122
|
+
}
|
|
123
|
+
return "";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
static #stops(el: Element, level: number) {
|
|
127
|
+
const found = headingLevels[el.tagName.toUpperCase() as keyof typeof headingLevels];
|
|
128
|
+
return !!found && found <= level;
|
|
66
129
|
}
|
|
67
130
|
|
|
68
131
|
#lines: string[] = [];
|
|
132
|
+
#headings: string[] = [];
|
|
69
133
|
#buffer = "";
|
|
70
134
|
#length = 0;
|
|
71
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Past the walk budget the text is dropped but the headings are not: a section below the cut is exactly what the
|
|
138
|
+
* truncation note owes the reader, and it is the only way that part of the screen can be named at all.
|
|
139
|
+
*/
|
|
140
|
+
#outline(node: Node): void {
|
|
141
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
142
|
+
const el = node as HTMLElement;
|
|
143
|
+
const tag = el.tagName.toUpperCase();
|
|
144
|
+
if (skipTags.has(tag) || el.hasAttribute("data-agent-ui")) return;
|
|
145
|
+
const level = headingLevels[tag as keyof typeof headingLevels];
|
|
146
|
+
if (level) {
|
|
147
|
+
const anchor = ScreenReader.anchorOf(el);
|
|
148
|
+
const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
149
|
+
if (text) this.#headings.push(`${"#".repeat(level)} ${text}${anchor ? ` (#${anchor})` : ""}`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
for (const child of el.childNodes) this.#outline(child);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#text() {
|
|
156
|
+
const text = this.#lines.join("\n").trim();
|
|
157
|
+
if (text.length <= ScreenReader.limit) return text;
|
|
158
|
+
const kept = text.slice(0, ScreenReader.limit);
|
|
159
|
+
const below = this.#headings.filter((heading) => !kept.includes(heading)).slice(0, 25);
|
|
160
|
+
const note = `… [truncated ${text.length - ScreenReader.limit} more characters]`;
|
|
161
|
+
return below.length
|
|
162
|
+
? `${kept}${note}\nFurther down, unread: ${below.join(" · ")}. Pass one of those names as \`section\` to read it.`
|
|
163
|
+
: `${kept}${note}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
72
166
|
#walk(node: Node): void {
|
|
73
|
-
if (this.#length > ScreenReader.limit * 2)
|
|
167
|
+
if (this.#length > ScreenReader.limit * 2) {
|
|
168
|
+
this.#outline(node);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
74
171
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
75
172
|
const text = (node.textContent ?? "").replace(/\s+/g, " ");
|
|
76
173
|
if (text.trim()) this.#buffer += text;
|
|
@@ -112,7 +209,11 @@ export class ScreenReader {
|
|
|
112
209
|
if (level) {
|
|
113
210
|
this.#flush();
|
|
114
211
|
this.#walkChildren(el);
|
|
212
|
+
const anchor = ScreenReader.anchorOf(el);
|
|
213
|
+
if (anchor) this.#buffer += ` (#${anchor})`;
|
|
214
|
+
const before = this.#lines.length;
|
|
115
215
|
this.#flush(`${"#".repeat(level)} `);
|
|
216
|
+
if (this.#lines.length > before) this.#headings.push(this.#lines[this.#lines.length - 1]);
|
|
116
217
|
return;
|
|
117
218
|
}
|
|
118
219
|
if (tag === "LI") {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface ScreenSettleOptions {
|
|
2
|
+
/** How long the DOM must hold still before the screen counts as settled. */
|
|
3
|
+
quietMs?: number;
|
|
4
|
+
/** How long to wait for a change that has not started yet — a navigation whose payload is still in flight. */
|
|
5
|
+
appearMs?: number;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Waits until the screen stops changing.
|
|
11
|
+
*
|
|
12
|
+
* The surface is read synchronously and the app is not: `router.push` returns while the RSC payload for the new
|
|
13
|
+
* route is still in flight, and a store action that fires `void fetch.*` commits a tick later. A tool that returns
|
|
14
|
+
* at either of those moments reports the screen it replaced — and the `readScreen` the agent calls next reads the
|
|
15
|
+
* page the user already left.
|
|
16
|
+
*
|
|
17
|
+
* Quiescence rather than a framework signal, because there is no one signal: the client router hands its promise
|
|
18
|
+
* to nobody, and a change may land in the store, in a refetch, or in a streamed Suspense boundary. A
|
|
19
|
+
* MutationObserver sees all three, and every wait is bounded — a screen that never holds still still answers.
|
|
20
|
+
*/
|
|
21
|
+
export class ScreenSettle {
|
|
22
|
+
static wait({ quietMs = 120, appearMs = 0, timeoutMs = 3000 }: ScreenSettleOptions = {}): Promise<void> {
|
|
23
|
+
const body = typeof document === "undefined" ? null : (document.body ?? document.documentElement);
|
|
24
|
+
if (!body || typeof MutationObserver === "undefined") return Promise.resolve();
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
let quiet: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
+
const done = () => {
|
|
28
|
+
if (quiet) clearTimeout(quiet);
|
|
29
|
+
clearTimeout(deadline);
|
|
30
|
+
observer.disconnect();
|
|
31
|
+
resolve();
|
|
32
|
+
};
|
|
33
|
+
const rearm = () => {
|
|
34
|
+
if (quiet) clearTimeout(quiet);
|
|
35
|
+
quiet = setTimeout(done, quietMs);
|
|
36
|
+
};
|
|
37
|
+
const observer = new MutationObserver(rearm);
|
|
38
|
+
const deadline = setTimeout(done, timeoutMs);
|
|
39
|
+
observer.observe(body, { attributes: true, characterData: true, childList: true, subtree: true });
|
|
40
|
+
|
|
41
|
+
if (appearMs) quiet = setTimeout(done, appearMs);
|
|
42
|
+
else rearm();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { ScreenReader } from "./ScreenReader";
|
|
2
|
+
|
|
3
|
+
const containerAttrs = ["data-agent-zone", "data-agent-scope"] as const;
|
|
4
|
+
const controlAttrs = ["data-akan-action", "data-akan-state"] as const;
|
|
5
|
+
const headingSelector = "h1, h2, h3, h4, h5, h6";
|
|
6
|
+
const nameCap = 40;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolves a name the agent read on screen to the element it names.
|
|
10
|
+
*
|
|
11
|
+
* Four vocabularies, every one of them something already on the screen rather than a selector the model invented:
|
|
12
|
+
* the `data-akan-action` / `data-akan-state` annotation a control carries and `readScreen` prints beside it, an
|
|
13
|
+
* `Agent.Zone` or `useScreenScope` container, a plain element id — what a docs slide is addressed by — and, last, a
|
|
14
|
+
* **heading by its own text**.
|
|
15
|
+
*
|
|
16
|
+
* A heading is matched on letters and digits alone, so the slug an agent naturally writes for a heading it read
|
|
17
|
+
* ("images-and-public-env") finds "Images And Public Env". That tolerance is for headings only: a heading is a
|
|
18
|
+
* landmark and scrolling to the wrong one costs nothing, while two buttons reading "Save" are not the same control
|
|
19
|
+
* and matching a *control* by its label would drive the wrong one.
|
|
20
|
+
*
|
|
21
|
+
* Nothing hidden ever resolves, by the same rule `readScreen` skips it. A collapsed panel or an off-variant
|
|
22
|
+
* duplicate is not what the user is looking at, and scrolling to one flashes a ring nobody can see — which reads
|
|
23
|
+
* as the tool being broken rather than as a miss.
|
|
24
|
+
*/
|
|
25
|
+
export class ScreenTarget {
|
|
26
|
+
static container(name: string, root?: HTMLElement | null): HTMLElement | null {
|
|
27
|
+
const scope = root ?? ScreenTarget.#body();
|
|
28
|
+
if (!scope || !name) return null;
|
|
29
|
+
if (ScreenTarget.#named(scope, containerAttrs, name)) return scope;
|
|
30
|
+
const escaped = CSS.escape(name);
|
|
31
|
+
const selector = [...containerAttrs.map((attr) => `[${attr}="${escaped}"]`), `#${escaped}`].join(", ");
|
|
32
|
+
return ScreenTarget.#first(scope, selector);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static control(name: string, root?: HTMLElement | null): HTMLElement | null {
|
|
36
|
+
const scope = root ?? ScreenTarget.#body();
|
|
37
|
+
if (!scope || !name) return null;
|
|
38
|
+
const escaped = CSS.escape(name);
|
|
39
|
+
return ScreenTarget.#first(scope, controlAttrs.map((attr) => `[${attr}="${escaped}"]`).join(", "));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Matched on letters and digits only, so a slug written for a heading still finds it. */
|
|
43
|
+
static heading(text: string, root?: HTMLElement | null): HTMLElement | null {
|
|
44
|
+
const scope = root ?? ScreenTarget.#body();
|
|
45
|
+
const wanted = ScreenTarget.#slug(text);
|
|
46
|
+
if (!scope || !wanted) return null;
|
|
47
|
+
const headings = [...scope.querySelectorAll<HTMLElement>(headingSelector)].filter(ScreenTarget.#visible);
|
|
48
|
+
return (
|
|
49
|
+
headings.find((heading) => ScreenTarget.#slug(heading.textContent ?? "") === wanted) ??
|
|
50
|
+
headings.find((heading) => ScreenTarget.#slug(heading.textContent ?? "").includes(wanted)) ??
|
|
51
|
+
null
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A control first — an annotated button is the most specific match — then a container, then a heading. */
|
|
56
|
+
static find(name: string, root?: HTMLElement | null): HTMLElement | null {
|
|
57
|
+
return ScreenTarget.control(name, root) ?? ScreenTarget.container(name, root) ?? ScreenTarget.heading(name, root);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The names a refusal can honestly offer for a region: the scope paths, plus every heading anchor on screen.
|
|
62
|
+
* Leaving the anchors out is what let a page of twenty named sections answer "nothing carries a name".
|
|
63
|
+
*/
|
|
64
|
+
static containerNames(root?: HTMLElement | null): string[] {
|
|
65
|
+
return [...new Set([...ScreenTarget.#names(containerAttrs, root), ...ScreenTarget.anchorNames(root)])].slice(
|
|
66
|
+
0,
|
|
67
|
+
nameCap,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static anchorNames(root?: HTMLElement | null): string[] {
|
|
72
|
+
const scope = root ?? ScreenTarget.#body();
|
|
73
|
+
if (!scope) return [];
|
|
74
|
+
const names = new Set<string>();
|
|
75
|
+
for (const heading of scope.querySelectorAll<HTMLElement>(headingSelector)) {
|
|
76
|
+
const anchor = ScreenTarget.#visible(heading) ? ScreenReader.anchorOf(heading) : "";
|
|
77
|
+
if (anchor) names.add(anchor);
|
|
78
|
+
}
|
|
79
|
+
return [...names];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static targetNames(root?: HTMLElement | null): string[] {
|
|
83
|
+
return [...new Set([...ScreenTarget.#names(controlAttrs, root), ...ScreenTarget.containerNames(root)])].slice(
|
|
84
|
+
0,
|
|
85
|
+
nameCap,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
static #names(attrs: readonly string[], root?: HTMLElement | null): string[] {
|
|
90
|
+
const scope = root ?? ScreenTarget.#body();
|
|
91
|
+
if (!scope) return [];
|
|
92
|
+
const names = new Set<string>();
|
|
93
|
+
for (const attr of attrs) {
|
|
94
|
+
const own = scope.getAttribute(attr);
|
|
95
|
+
if (own) names.add(own);
|
|
96
|
+
for (const el of scope.querySelectorAll<HTMLElement>(`[${attr}]`)) {
|
|
97
|
+
const value = ScreenTarget.#visible(el) ? el.getAttribute(attr) : null;
|
|
98
|
+
if (value) names.add(value);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return [...names].slice(0, nameCap);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
static #named(el: HTMLElement, attrs: readonly string[], name: string) {
|
|
105
|
+
return attrs.some((attr) => el.getAttribute(attr) === name);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
static #first(scope: HTMLElement, selector: string) {
|
|
109
|
+
return [...scope.querySelectorAll<HTMLElement>(selector)].find(ScreenTarget.#visible) ?? null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
static #visible(el: HTMLElement) {
|
|
113
|
+
if (el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true") return false;
|
|
114
|
+
if (el.closest("[data-agent-ui]")) return false;
|
|
115
|
+
return typeof el.checkVisibility === "function" ? el.checkVisibility() : true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
static #slug(text: string) {
|
|
119
|
+
return text
|
|
120
|
+
.toLowerCase()
|
|
121
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
122
|
+
.trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
static #body(): HTMLElement | null {
|
|
126
|
+
if (typeof document === "undefined") return null;
|
|
127
|
+
return document.body ?? document.documentElement;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -2,17 +2,25 @@ import { router } from "akanjs/client";
|
|
|
2
2
|
import type { SurfaceSource, ToolEntry } from "../../vendor/use-agentic";
|
|
3
3
|
import { AgentBridge } from "./AgentBridge";
|
|
4
4
|
import { ScreenReader } from "./ScreenReader";
|
|
5
|
+
import { ScreenSettle } from "./ScreenSettle";
|
|
6
|
+
import { ScreenTarget } from "./ScreenTarget";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
|
-
* The
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* The tools every akan screen has whatever it declares: where it can go, what it is rendering, what one of the
|
|
10
|
+
* store keys it reads holds, and where on screen a thing the user is being told about actually is. Everything else
|
|
11
|
+
* an agent may do is a component's own `st.tool` declaration — the store contributes no actions, because a method
|
|
12
|
+
* on a store class is not something the screen offers the user.
|
|
10
13
|
*
|
|
11
|
-
* Per zone view: a zone's `readState` reaches the keys its own subtree subscribes, and its `readScreen`
|
|
12
|
-
* own `data-agent-zone` container rather than the whole document. A page can shadow any
|
|
13
|
-
* registering a hook tool of the same name — hook entries win over a source's.
|
|
14
|
+
* Per zone view: a zone's `readState` reaches the keys its own subtree subscribes, and its `readScreen` and
|
|
15
|
+
* `highlight` reach into its own `data-agent-zone` container rather than the whole document. A page can shadow any
|
|
16
|
+
* of them by registering a hook tool of the same name — hook entries win over a source's.
|
|
14
17
|
*/
|
|
15
18
|
export class StoreSurfaceSource implements SurfaceSource {
|
|
19
|
+
/** Defined in `akanjs/ui/styles.css`, so the flash follows the app's own theme tokens. */
|
|
20
|
+
static readonly highlightClass = "akan-agent-highlight";
|
|
21
|
+
/** Mirrors the animation in that stylesheet: the class outlives the ring by nothing. */
|
|
22
|
+
static readonly highlightMs = 2400;
|
|
23
|
+
|
|
16
24
|
#bridge: AgentBridge | null;
|
|
17
25
|
readonly #builtins = new Map<string, ToolEntry[]>();
|
|
18
26
|
|
|
@@ -25,7 +33,13 @@ export class StoreSurfaceSource implements SurfaceSource {
|
|
|
25
33
|
const viewKey = view.join(".");
|
|
26
34
|
let builtins = this.#builtins.get(viewKey);
|
|
27
35
|
if (!builtins) {
|
|
28
|
-
builtins = [
|
|
36
|
+
builtins = [
|
|
37
|
+
StoreSurfaceSource.#navigate(),
|
|
38
|
+
StoreSurfaceSource.#goBack(),
|
|
39
|
+
StoreSurfaceSource.#readScreen(viewKey),
|
|
40
|
+
this.#readState(viewKey),
|
|
41
|
+
StoreSurfaceSource.#highlight(viewKey),
|
|
42
|
+
];
|
|
29
43
|
this.#builtins.set(viewKey, builtins);
|
|
30
44
|
}
|
|
31
45
|
return builtins;
|
|
@@ -48,24 +62,112 @@ export class StoreSurfaceSource implements SurfaceSource {
|
|
|
48
62
|
typeof args.path === "string" && args.path.startsWith("/") && !args.path.startsWith("//")
|
|
49
63
|
? true
|
|
50
64
|
: "path must be an internal path starting with /.",
|
|
51
|
-
run: (args) => {
|
|
65
|
+
run: async (args) => {
|
|
52
66
|
const path = String(args.path);
|
|
53
67
|
router.push(path);
|
|
54
|
-
|
|
68
|
+
|
|
69
|
+
await ScreenSettle.wait({ appearMs: 800, timeoutMs: 5000 });
|
|
70
|
+
return `Now on ${path}. Call readScreen to see it; this screen's own tools and state are listed from the next turn.`;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Global rather than declared by whatever `Link.Back` happens to be on screen: history is not a control the page
|
|
77
|
+
* owns. Every route has a previous page, the browser's own back gesture is always there, and a page that draws
|
|
78
|
+
* no back link is not a page you may not leave — the same reasoning that makes `navigate` a built-in.
|
|
79
|
+
*/
|
|
80
|
+
static #goBack(): ToolEntry {
|
|
81
|
+
return {
|
|
82
|
+
name: "goBack",
|
|
83
|
+
description:
|
|
84
|
+
"Go back to the previous page in this session's history. Use it to undo a navigation; use navigate for a path.",
|
|
85
|
+
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
86
|
+
effect: "state",
|
|
87
|
+
|
|
88
|
+
guard: () => (router.canGoBack() ? true : "There is no previous page in this session's history."),
|
|
89
|
+
run: async () => {
|
|
90
|
+
router.back();
|
|
91
|
+
await ScreenSettle.wait({ appearMs: 800, timeoutMs: 5000 });
|
|
92
|
+
return `Back on ${router.getPath()}. Call readScreen to see it; this screen's own tools and state are listed from the next turn.`;
|
|
55
93
|
},
|
|
56
94
|
};
|
|
57
95
|
}
|
|
58
96
|
|
|
59
97
|
/** What the user is looking at, read from the rendered DOM — the store never held it, so no key can answer it. */
|
|
60
98
|
static #readScreen(viewKey: string): ToolEntry {
|
|
99
|
+
const where = viewKey ? "in this zone" : "on this page";
|
|
100
|
+
const subject = viewKey ? "this zone" : "the current page";
|
|
61
101
|
return {
|
|
62
102
|
name: "readScreen",
|
|
63
|
-
description:
|
|
64
|
-
|
|
65
|
-
: "
|
|
66
|
-
|
|
103
|
+
description: `Read what is currently rendered ${where} — headings, prose, links, buttons, and form values. Use it when the user asks about what ${subject} shows. A long screen is truncated, so pass \`section\` to read one part of it.`,
|
|
104
|
+
parameters: {
|
|
105
|
+
type: "object",
|
|
106
|
+
properties: {
|
|
107
|
+
section: {
|
|
108
|
+
type: "string",
|
|
109
|
+
description:
|
|
110
|
+
"One region of the screen: a heading's anchor as readScreen prints it, the heading's own text, or a scope path from the screen context. Omit to read all of it.",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
additionalProperties: false,
|
|
114
|
+
},
|
|
67
115
|
effect: "query",
|
|
68
|
-
run: () =>
|
|
116
|
+
run: (args) => {
|
|
117
|
+
const root = StoreSurfaceSource.#zoneRoot(viewKey);
|
|
118
|
+
const section = typeof args.section === "string" ? args.section.trim() : "";
|
|
119
|
+
if (!section) return ScreenReader.read(root);
|
|
120
|
+
const container = ScreenTarget.container(section, root);
|
|
121
|
+
if (container) return ScreenReader.read(container);
|
|
122
|
+
const heading = ScreenTarget.heading(section, root);
|
|
123
|
+
if (heading) return ScreenReader.readFrom(heading, root);
|
|
124
|
+
const named = ScreenTarget.containerNames(root);
|
|
125
|
+
throw new Error(
|
|
126
|
+
`No section named ${section} is on screen. ${
|
|
127
|
+
named.length
|
|
128
|
+
? `Sections here: ${named.join(", ")}.`
|
|
129
|
+
: "This screen names no sections; omit section to read all of it."
|
|
130
|
+
}`,
|
|
131
|
+
);
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Showing beats describing: an agent that has just been asked where something is can put it in front of the
|
|
138
|
+
* user instead of writing directions to it. It drives nothing and changes no data — it is the one built-in that
|
|
139
|
+
* exists for the *user's* benefit rather than the model's, which is why it is worth a slot on every screen.
|
|
140
|
+
*/
|
|
141
|
+
static #highlight(viewKey: string): ToolEntry {
|
|
142
|
+
return {
|
|
143
|
+
name: "highlight",
|
|
144
|
+
description:
|
|
145
|
+
"Scroll one thing into view and flash it, to show the user where it is instead of describing where it is. `target` is a tool name as it appears beside a control in readScreen, a state key, a scope path, a heading's anchor, or a heading's own text.",
|
|
146
|
+
parameters: {
|
|
147
|
+
type: "object",
|
|
148
|
+
properties: { target: { type: "string" } },
|
|
149
|
+
required: ["target"],
|
|
150
|
+
additionalProperties: false,
|
|
151
|
+
},
|
|
152
|
+
effect: "state",
|
|
153
|
+
run: (args) => {
|
|
154
|
+
const name = typeof args.target === "string" ? args.target.trim() : "";
|
|
155
|
+
if (!name) throw new Error("highlight needs a target.");
|
|
156
|
+
const root = StoreSurfaceSource.#zoneRoot(viewKey);
|
|
157
|
+
if (typeof document === "undefined") return "No rendered document is available.";
|
|
158
|
+
const target = ScreenTarget.find(name, root);
|
|
159
|
+
if (!target) {
|
|
160
|
+
const named = ScreenTarget.targetNames(root);
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Nothing named ${name} is on screen. ${
|
|
163
|
+
named.length ? `On screen now: ${named.join(", ")}.` : "Nothing on this screen carries a name."
|
|
164
|
+
}`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
target.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
168
|
+
StoreSurfaceSource.#flash(target);
|
|
169
|
+
return `Highlighted ${name} on screen for the user.`;
|
|
170
|
+
},
|
|
69
171
|
};
|
|
70
172
|
}
|
|
71
173
|
|
|
@@ -88,6 +190,30 @@ export class StoreSurfaceSource implements SurfaceSource {
|
|
|
88
190
|
};
|
|
89
191
|
}
|
|
90
192
|
|
|
193
|
+
/**
|
|
194
|
+
* The ring goes on once the scroll lands, not when it starts: a smooth scroll across a long page takes most of a
|
|
195
|
+
* second, and a flash begun at the top is already fading by the time the user's eye arrives. Settles on the
|
|
196
|
+
* element's own position rather than a scroll event, which no browser fires consistently, and is capped so a page
|
|
197
|
+
* that never stops moving still flashes. Removed on a timer — a React re-render that drops the class early only
|
|
198
|
+
* ends it sooner.
|
|
199
|
+
*/
|
|
200
|
+
static #flash(target: HTMLElement) {
|
|
201
|
+
let last = Number.NaN;
|
|
202
|
+
let frames = 0;
|
|
203
|
+
const settle = () => {
|
|
204
|
+
const { top } = target.getBoundingClientRect();
|
|
205
|
+
frames += 1;
|
|
206
|
+
if (Math.abs(top - last) >= 1 && frames < 90) {
|
|
207
|
+
last = top;
|
|
208
|
+
requestAnimationFrame(settle);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
target.classList.add(StoreSurfaceSource.highlightClass);
|
|
212
|
+
setTimeout(() => target.classList.remove(StoreSurfaceSource.highlightClass), StoreSurfaceSource.highlightMs);
|
|
213
|
+
};
|
|
214
|
+
requestAnimationFrame(settle);
|
|
215
|
+
}
|
|
216
|
+
|
|
91
217
|
static #zoneRoot(viewKey: string): HTMLElement | undefined {
|
|
92
218
|
if (!viewKey || typeof document === "undefined") return undefined;
|
|
93
219
|
return document.querySelector<HTMLElement>(`[data-agent-zone="${CSS.escape(viewKey)}"]`) ?? undefined;
|
package/store/agent/index.ts
CHANGED
|
@@ -2,6 +2,8 @@ export * from "./AgentBridge";
|
|
|
2
2
|
export * from "./AgentContext";
|
|
3
3
|
export * from "./AgentPrompts";
|
|
4
4
|
export * from "./ScreenReader";
|
|
5
|
+
export * from "./ScreenSettle";
|
|
6
|
+
export * from "./ScreenTarget";
|
|
5
7
|
export * from "./StoreCatalogue";
|
|
6
8
|
export * from "./StoreSurfaceSource";
|
|
7
9
|
export * from "./storeSurface";
|