acrylblends 1.1.0 → 1.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/README.md +9 -0
- package/dist/index.d.ts +45 -1
- package/dist/index.js +1980 -27
- package/dist/index.js.map +1 -1
- package/docs/framework/agents/index.md +1 -1
- package/docs/framework/getting-started/desktop.md +23 -4
- package/docs/framework/guides/create-acrylblends.md +1 -1
- package/docs/framework/workshop/README.md +138 -152
- package/docs/framework/workshop/facilitator.md +69 -101
- package/legacy/README.md +9 -0
- package/legacy/agent/157ce3db1112/src/app.ts +98 -0
- package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
- package/legacy/agent/24c681a00fca/src/app.ts +160 -0
- package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
- package/legacy/agent/2e790da977b2/src/app.ts +98 -0
- package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
- package/legacy/agent/6307045545dc/src/app.ts +168 -0
- package/legacy/agent/6307045545dc/src/routes.ts +83 -0
- package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
- package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
- package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
- package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
- package/legacy/agent/74ece148269c/src/app.ts +168 -0
- package/legacy/agent/74ece148269c/src/routes.ts +93 -0
- package/legacy/agent/855359304707/src/app.ts +168 -0
- package/legacy/agent/855359304707/src/routes.ts +126 -0
- package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
- package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
- package/legacy/agent/c2f30958043b/src/app.ts +98 -0
- package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
- package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
- package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
- package/legacy/blank/aea589283445/src/routes.ts +16 -0
- package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
- package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
- package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
- package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
- package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
- package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
- package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
- package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
- package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
- package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
- package/legacy/layouts.json +524 -0
- package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
- package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
- package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
- package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
- package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
- package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
- package/legacy/notes/4edc36949c15/src/app.ts +107 -0
- package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
- package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
- package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
- package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
- package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
- package/legacy/todo/02e613b3625c/src/app.ts +71 -0
- package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
- package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
- package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
- package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
- package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
- package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
- package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
- package/legacy/todo/8e64211312f3/src/app.ts +71 -0
- package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
- package/legacy/todo/b79078a87565/src/app.ts +71 -0
- package/legacy/todo/b79078a87565/src/todo.ts +125 -0
- package/lib/{audit-BFpULBCj.d.ts → audit-Bok_UNCn.d.ts} +26 -3
- package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
- package/lib/cordis/door.d.ts +42 -7
- package/lib/cordis/door.js +204 -60
- package/lib/cordis/inspect.js +1 -1
- package/lib/cordis/node-server.d.ts +101 -3
- package/lib/cordis/node-server.js +3 -3
- package/lib/cordis/router-http.d.ts +7 -1
- package/lib/cordis/router-http.js +44 -3
- package/lib/cordis/router.d.ts +1 -1
- package/lib/cordis/router.js +1 -1
- package/lib/cordis/state.js +1 -1
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +10 -1
- package/lib/door/page.js +46 -7
- package/lib/door/protocol.d.ts +1 -1
- package/lib/drivers.d.ts +10 -1
- package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
- package/lib/node-server.d.ts +7 -1
- package/lib/node-server.js +2 -2
- package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
- package/lib/{snapshot-text-BwM6T2nG.js → snapshot-text-CXTnjxGx.js} +87 -7
- package/lib/src-CtkIX_Gg.js +427 -0
- package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
- package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
- package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
- package/package.json +2 -1
- package/plugins/acryl-door/src/host/channel.ts +86 -9
- package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
- package/plugins/acryl-door/src/host/door-file.ts +7 -2
- package/plugins/acryl-door/src/host/door.ts +53 -23
- package/plugins/acryl-door/src/host/page-routes.ts +3 -1
- package/plugins/acryl-door/src/host/page-socket.ts +18 -8
- package/plugins/acryl-door/src/host/verbs.ts +3 -0
- package/plugins/acryl-door/src/index.ts +166 -29
- package/plugins/acryl-door/src/page/base.ts +32 -0
- package/plugins/acryl-door/src/page/channel.ts +3 -1
- package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +32 -4
- package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
- package/plugins/acryl-door/src/page/index.ts +41 -2
- package/plugins/acryl-door/src/page/outline.ts +56 -0
- package/plugins/acryl-door/src/page/protocol.ts +4 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
- package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
- package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
- package/plugins/acryl-node-server/src/client-modules.ts +7 -1
- package/plugins/acryl-node-server/src/index.ts +304 -16
- package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
- package/plugins/acryl-page/plugin.json +12 -0
- package/plugins/acryl-page/src/index.ts +163 -0
- package/plugins/acryl-router/src/service.ts +77 -14
- package/plugins/acryl-router-http/src/index.ts +15 -1
- package/plugins/acryl-router-http/src/serve.ts +31 -0
- package/plugins/acryl-web-server/plugin.json +14 -0
- package/plugins/acryl-web-server/src/index.ts +164 -0
- package/plugins/agent/agent.ts +38 -7
- package/plugins/agent/index.ts +1 -1
- package/plugins/agent/model.ts +10 -2
- package/plugins/agent/tools.ts +9 -3
- package/plugins/agent-panel/client/describe.ts +57 -0
- package/plugins/agent-panel/client/driver-pick.ts +22 -0
- package/plugins/agent-panel/client/frame.ts +51 -0
- package/plugins/agent-panel/client/main.ts +238 -26
- package/plugins/agent-panel/drivers.ts +7 -2
- package/plugins/agent-panel/headless.ts +5 -2
- package/plugins/agent-panel/index.ts +146 -57
- package/plugins/agent-panel/mount.ts +27 -0
- package/plugins/agent-panel/visitors.ts +231 -0
- package/plugins/desktop/frame.ts +231 -0
- package/plugins/desktop/launch.ts +44 -9
- package/plugins/desktop/main.ts +100 -32
- package/plugins/desktop/window.ts +21 -4
- package/plugins/driver-pty/client/terminal.ts +4 -1
- package/plugins/driver-pty/index.ts +38 -11
- package/plugins/driver-pty/spawn.ts +38 -0
- package/plugins/driver-pty/terminal.ts +2 -0
- package/plugins/extensions/index.ts +26 -1
- package/plugins/extensions/resolve.ts +13 -0
- package/plugins/feedback/client.d.ts +22 -0
- package/plugins/feedback/client.js +471 -0
- package/plugins/feedback/index.ts +143 -0
- package/plugins/feedback/plugin.json +11 -0
- package/plugins/feedback/store.ts +307 -0
- package/plugins/home/index.ts +122 -0
- package/plugins/home/plugin.json +11 -0
- package/plugins/instance/index.ts +6 -0
- package/plugins/manifest/app-plugins.ts +86 -0
- package/plugins/manifest/index.ts +12 -2
- package/plugins/manifest/snapshot.ts +275 -0
- package/plugins/openai/index.ts +64 -5
- package/plugins/openai/prompt.ts +23 -0
- package/shared/AGENTS.core.md +36 -16
- package/shared/skills/acryl-add-ui/SKILL.md +19 -0
- package/shared/skills/acryl-app/SKILL.md +13 -0
- package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
- package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
- package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
- package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
- package/shared/skills/cordis-plugin/SKILL.md +26 -0
- package/templates/agent/AGENTS.md +1 -1
- package/templates/agent/blend.yaml +4 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +1 -0
- package/templates/agent/src/app.ts +150 -33
- package/templates/agent/src/frame.ts +17 -0
- package/templates/agent/src/main.ts +5 -3
- package/templates/agent/src/routes.ts +38 -19
- package/templates/agent/template.json +2 -0
- package/templates/agent/test/app.test.ts +45 -3
- package/templates/blank/AGENTS.md +3 -2
- package/templates/blank/blend.yaml +11 -0
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +1 -0
- package/templates/blank/src/app.ts +100 -29
- package/templates/blank/src/door.ts +7 -0
- package/templates/blank/src/first-page.ts +5 -0
- package/templates/blank/src/frame.ts +17 -0
- package/templates/blank/src/routes.ts +65 -8
- package/templates/blank/template.json +14 -1
- package/templates/blank/test/app.test.ts +103 -16
- package/templates/chat/blend.yaml +2 -1
- package/templates/chat/gitignore +1 -0
- package/templates/chat/package.json +1 -0
- package/templates/chat/src/app.ts +72 -22
- package/templates/chat/src/chat-app.ts +11 -2
- package/templates/chat/src/client/chat.tsx +4 -2
- package/templates/chat/src/frame.ts +17 -0
- package/templates/chat/src/page.tsx +6 -4
- package/templates/chat/src/routes.ts +14 -4
- package/templates/chat/src/runtime.ts +9 -3
- package/templates/chat/template.json +3 -1
- package/templates/chat/test/chat.test.ts +46 -1
- package/templates/chat/test/server.test.ts +17 -3
- package/templates/nodejs/blend.yaml +2 -1
- package/templates/nodejs/gitignore +1 -0
- package/templates/nodejs/package.json +1 -0
- package/templates/nodejs/src/app.ts +76 -21
- package/templates/nodejs/src/frame.ts +17 -0
- package/templates/nodejs/src/page.tsx +3 -3
- package/templates/nodejs/src/routes.ts +32 -13
- package/templates/nodejs/template.json +3 -1
- package/templates/nodejs/test/app.test.ts +38 -3
- package/templates/nodejs/test/server.test.ts +17 -3
- package/templates/notes/blend.yaml +2 -1
- package/templates/notes/gitignore +1 -0
- package/templates/notes/package.json +1 -0
- package/templates/notes/src/app.ts +72 -22
- package/templates/notes/src/frame.ts +17 -0
- package/templates/notes/src/notes-app.ts +24 -17
- package/templates/notes/src/routes.ts +11 -4
- package/templates/notes/src/runtime.ts +9 -3
- package/templates/notes/src/views.tsx +26 -17
- package/templates/notes/template.json +3 -1
- package/templates/notes/test/notes.test.ts +39 -1
- package/templates/notes/test/server.test.ts +17 -3
- package/templates/todo/blend.yaml +3 -0
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +1 -0
- package/templates/todo/src/app.ts +81 -27
- package/templates/todo/src/door.ts +7 -0
- package/templates/todo/src/frame.ts +17 -0
- package/templates/todo/src/page.ts +13 -6
- package/templates/todo/src/todo.ts +18 -6
- package/templates/todo/template.json +11 -1
- package/templates/todo/test/todo.test.ts +67 -9
- package/lib/src-LX0E4ec4.js +0 -117
- package/shared/SKILL.md +0 -14
|
@@ -16,6 +16,8 @@ export interface ActionEnvironment {
|
|
|
16
16
|
readonly document: () => Document
|
|
17
17
|
/** Resolves after `ms`; rejects when `signal` aborts. Injectable so tests need no real time. */
|
|
18
18
|
readonly sleep: (ms: number, signal: AbortSignal) => Promise<void>
|
|
19
|
+
/** Called with the element an act is about to use, so the page can show the person what the agent is touching. */
|
|
20
|
+
readonly touched?: (element: Element) => void
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
const target = (role: string, name: string): { role: string; name: string } => ({ role, name })
|
|
@@ -35,6 +37,7 @@ function prepare(env: ActionEnvironment, ref: string): { element: Element; role:
|
|
|
35
37
|
if ((element as HTMLButtonElement).disabled === true || element.getAttribute('aria-disabled') === 'true') {
|
|
36
38
|
throw new UiControlError('not-actionable', `"${resolved.name}" is disabled`)
|
|
37
39
|
}
|
|
40
|
+
env.touched?.(element)
|
|
38
41
|
return resolved
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -149,6 +152,7 @@ export function scroll(env: ActionEnvironment, request: Request<'scroll'>): UiAc
|
|
|
149
152
|
const dy = request.direction === 'up' ? -amount : request.direction === 'down' ? amount : 0
|
|
150
153
|
if (request.ref !== undefined) {
|
|
151
154
|
const { element, role, name } = env.refs.resolve(request.ref)
|
|
155
|
+
env.touched?.(element)
|
|
152
156
|
;(element as HTMLElement).scrollBy?.({ left: dx, top: dy })
|
|
153
157
|
if (typeof (element as HTMLElement).scrollBy !== 'function') { element.scrollTop += dy; element.scrollLeft += dx }
|
|
154
158
|
return { ok: true, target: target(role, name), detail: `scrolled ${request.direction}` }
|
|
@@ -27,11 +27,16 @@ export interface DriverActivity {
|
|
|
27
27
|
readonly killed: boolean
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** How long after an act a navigation it started is still noticed (a link's navigation starts a task or two after the click). */
|
|
31
|
+
const LEAVE_WATCH_MS = 50
|
|
32
|
+
|
|
30
33
|
export interface UiDriverOptions {
|
|
31
34
|
readonly document: () => Document
|
|
32
35
|
/** Milliseconds since the user last used the page (mouse, keyboard); Infinity when never. */
|
|
33
36
|
readonly msSinceUserInput?: () => number
|
|
34
37
|
readonly sleep?: (ms: number, signal: AbortSignal) => Promise<void>
|
|
38
|
+
/** Called with the element an act is about to use, for the page to show it (the door's outline on what the agent touches). */
|
|
39
|
+
readonly touched?: (element: Element) => void
|
|
35
40
|
/** Leave the page: reload it, or go to a path of the app. Default: the document's own `location`, a moment after the answer is sent. */
|
|
36
41
|
readonly leave?: (to: { readonly reload: true } | { readonly path: string }) => void
|
|
37
42
|
}
|
|
@@ -63,7 +68,12 @@ export class UiDriver {
|
|
|
63
68
|
constructor(options: UiDriverOptions) {
|
|
64
69
|
this.options = options
|
|
65
70
|
this.snapshots = new SnapshotBuilder(this.refs, () => options.document().body)
|
|
66
|
-
this.env = {
|
|
71
|
+
this.env = {
|
|
72
|
+
refs: this.refs,
|
|
73
|
+
document: options.document,
|
|
74
|
+
sleep: options.sleep ?? realSleep,
|
|
75
|
+
...(options.touched === undefined ? {} : { touched: options.touched }),
|
|
76
|
+
}
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
/** The current activity; the same object until something changes, as a store snapshot must be. */
|
|
@@ -142,10 +152,10 @@ export class UiDriver {
|
|
|
142
152
|
private async run(request: UiRequest, signal: AbortSignal): Promise<UiResult> {
|
|
143
153
|
switch (request.op) {
|
|
144
154
|
case 'snapshot': return this.snapshots.snapshot({ ...(request.cursor === undefined ? {} : { cursor: request.cursor }), ...(request.maxNodes === undefined ? {} : { maxNodes: request.maxNodes }) })
|
|
145
|
-
case 'click': return click(this.env, request)
|
|
146
|
-
case 'type': return typeText(this.env, request)
|
|
155
|
+
case 'click': return await this.watchLeaving(() => click(this.env, request), signal)
|
|
156
|
+
case 'type': return await this.watchLeaving(() => typeText(this.env, request), signal)
|
|
147
157
|
case 'select': return selectOption(this.env, request)
|
|
148
|
-
case 'press': return pressKey(this.env, request)
|
|
158
|
+
case 'press': return await this.watchLeaving(() => pressKey(this.env, request), signal)
|
|
149
159
|
case 'scroll': return scroll(this.env, request)
|
|
150
160
|
case 'wait': return wait(this.env, request, signal)
|
|
151
161
|
case 'reload': return this.leave({ reload: true }, 'reloading')
|
|
@@ -153,6 +163,24 @@ export class UiDriver {
|
|
|
153
163
|
}
|
|
154
164
|
}
|
|
155
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Run an act that may start a navigation (a click on a link, Enter in a form) and say so: the browser asks `beforeunload` when a navigation starts, during the act or a
|
|
168
|
+
* moment after it. The answer is sent before the page goes; the Host then waits for the next page instead of reading this one again.
|
|
169
|
+
*/
|
|
170
|
+
private async watchLeaving(act: () => UiActionResult, signal: AbortSignal): Promise<UiActionResult> {
|
|
171
|
+
const view = this.options.document().defaultView
|
|
172
|
+
let leaving = false
|
|
173
|
+
const onLeave = (): void => { leaving = true }
|
|
174
|
+
view?.addEventListener('beforeunload', onLeave)
|
|
175
|
+
try {
|
|
176
|
+
const result = act()
|
|
177
|
+
if (!leaving) await this.env.sleep(LEAVE_WATCH_MS, signal)
|
|
178
|
+
return leaving ? { ...result, leaving: true, detail: `${result.detail === undefined ? '' : `${result.detail}; `}the page is going to another address` } : result
|
|
179
|
+
} finally {
|
|
180
|
+
view?.removeEventListener('beforeunload', onLeave)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
156
184
|
/**
|
|
157
185
|
* The page is about to be replaced, so the answer is given first and the move happens a moment later (the answer is a POST that the page's own unload would cut off).
|
|
158
186
|
* The Host does not wait for this answer to be the last word: it waits for the page to come back.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Builds a bounded, redacted accessibility snapshot of the page, with refs. */
|
|
2
2
|
|
|
3
3
|
import { DEFAULT_SNAPSHOT_NODES, MAX_SNAPSHOT_NODES, UiControlError, type UiNode, type UiSnapshot } from '../protocol.ts'
|
|
4
|
-
import { isHidden, levelOf, NAMED_BY_CONTENT, nameOf, roleOf, SHOWN_AS_VALUE, statesOf, TEXT_ROLE, textRun, valueOf } from './accessibility.ts'
|
|
4
|
+
import { isHidden, levelOf, NAMED_BY_CONTENT, nameOf, optionsOf, roleOf, SHOWN_AS_VALUE, statesOf, TEXT_ROLE, textRun, valueOf } from './accessibility.ts'
|
|
5
5
|
import type { RefTable } from './ref-table.ts'
|
|
6
6
|
import { isProtected, isSensitive } from './sensitivity.ts'
|
|
7
7
|
|
|
@@ -90,7 +90,8 @@ export class SnapshotBuilder {
|
|
|
90
90
|
const root = this.root()
|
|
91
91
|
const view = root.ownerDocument.defaultView
|
|
92
92
|
let offset = options.cursor ?? 0
|
|
93
|
-
|
|
93
|
+
// Cursor 0 is the first page, which is a new snapshot: a model that passes it on its first look gets one instead of a stale-ref.
|
|
94
|
+
if (offset === 0) {
|
|
94
95
|
this.refs.begin()
|
|
95
96
|
const all = collect(root)
|
|
96
97
|
// What the user can see comes first, so a long page is cut from the bottom, not the middle of the window.
|
|
@@ -105,6 +106,7 @@ export class SnapshotBuilder {
|
|
|
105
106
|
if (isProtected(candidate.element)) states.push('protected')
|
|
106
107
|
const value = valueOf(candidate.element, candidate.role)
|
|
107
108
|
const level = candidate.role === 'heading' ? levelOf(candidate.element) : undefined
|
|
109
|
+
const options = optionsOf(candidate.element)
|
|
108
110
|
return {
|
|
109
111
|
ref: this.refs.issue(candidate.element, candidate.role, candidate.name),
|
|
110
112
|
role: candidate.role,
|
|
@@ -113,6 +115,7 @@ export class SnapshotBuilder {
|
|
|
113
115
|
states,
|
|
114
116
|
...(value === undefined ? {} : { value }),
|
|
115
117
|
...(level === undefined ? {} : { level }),
|
|
118
|
+
...(options === undefined ? {} : { options }),
|
|
116
119
|
}
|
|
117
120
|
})
|
|
118
121
|
const end = offset + page.length
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* the kill switch. It touches only this document, never another window and never the OS.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { appBaseOf, underAppBase } from './base.ts'
|
|
7
8
|
import { PageChannel } from './channel.ts'
|
|
8
9
|
import { UiDriver } from './driver/driver.ts'
|
|
9
10
|
import { mountIndicator } from './indicator.ts'
|
|
11
|
+
import { mountOutlines } from './outline.ts'
|
|
10
12
|
import { UserInputClock } from './user-input.ts'
|
|
11
13
|
|
|
12
14
|
export const DOOR_BASE = '/api/acryl-door'
|
|
@@ -14,14 +16,50 @@ export const DOOR_BASE = '/api/acryl-door'
|
|
|
14
16
|
/** The key on the page's `window` under which the door publishes `{ windowId }`: the agent panel in the same page sends it with a question, and the door drives that page for the run. */
|
|
15
17
|
export const PAGE_KEY = Symbol.for('acryl.door.page')
|
|
16
18
|
|
|
19
|
+
/** Where the page keeps its tab's id: per tab, kept across reloads and moves within the app, never shared with another tab. */
|
|
20
|
+
const TAB_KEY = 'acryl-door-tab'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* This tab's id: the page that replaces this one in the same tab (a reload, a move to another path) says the same, so the Host keeps driving that tab for a run asked from it.
|
|
24
|
+
* `undefined` when the browser keeps no session storage.
|
|
25
|
+
*/
|
|
26
|
+
function tabOf(view: Window): string | undefined {
|
|
27
|
+
try {
|
|
28
|
+
const kept = view.sessionStorage.getItem(TAB_KEY)
|
|
29
|
+
if (kept !== null && /^[A-Za-z0-9-]{8,80}$/u.test(kept)) return kept
|
|
30
|
+
const made = typeof view.crypto?.randomUUID === 'function' ? view.crypto.randomUUID() : undefined
|
|
31
|
+
if (made === undefined) return undefined
|
|
32
|
+
view.sessionStorage.setItem(TAB_KEY, made)
|
|
33
|
+
return made
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
/** Start the door on `document`. @returns the disposer. */
|
|
18
40
|
export function startDoor(document: Document, base: string = DOOR_BASE): () => void {
|
|
19
41
|
const view = document.defaultView
|
|
20
42
|
if (view === null) throw new Error('acryl-door: the document has no window')
|
|
21
43
|
const clock = new UserInputClock()
|
|
22
|
-
const
|
|
44
|
+
const appBase = appBaseOf(document)
|
|
45
|
+
// The ring on whatever the agent touches, drawn in this page (the frame's canvas and production's assistant both get it).
|
|
46
|
+
const outlines = mountOutlines(document)
|
|
47
|
+
const driver = new UiDriver({
|
|
48
|
+
document: () => document,
|
|
49
|
+
msSinceUserInput: clock.msSince,
|
|
50
|
+
touched: (element) => { outlines.touched(element) },
|
|
51
|
+
// A host that serves the app under a prefix (the ACRYL frame's canvas) marks its pages: an app path navigates under that prefix, so the same
|
|
52
|
+
// `act navigate --path /board` reaches the app's /board in the frame and on the app's own server.
|
|
53
|
+
...(appBase === '' ? {} : {
|
|
54
|
+
leave: (to: { readonly reload: true } | { readonly path: string }): void => {
|
|
55
|
+
if ('reload' in to) view.location.reload()
|
|
56
|
+
else view.location.assign(underAppBase(appBase, to.path))
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
})
|
|
23
60
|
const windowId = typeof view.crypto?.randomUUID === 'function' ? view.crypto.randomUUID() : `w-${String(Date.now())}-${String(Math.random()).slice(2, 8)}`
|
|
24
61
|
Object.defineProperty(view, PAGE_KEY, { value: Object.freeze({ windowId }), configurable: true })
|
|
62
|
+
const tab = tabOf(view)
|
|
25
63
|
const focused = (): boolean => document.visibilityState === 'visible' && document.hasFocus()
|
|
26
64
|
const detachInput = clock.attach(document)
|
|
27
65
|
const removeIndicator = mountIndicator(driver, document)
|
|
@@ -34,7 +72,7 @@ export function startDoor(document: Document, base: string = DOOR_BASE): () => v
|
|
|
34
72
|
return 'auto'
|
|
35
73
|
}
|
|
36
74
|
})()
|
|
37
|
-
const channel = new PageChannel(driver, { windowId, focused, base, transport, onRun: running => { driver.setRunning(running) }, ...(Number.isSafeInteger(shellWindow) && shellWindow > 0 ? { shellWindow } : {}) })
|
|
75
|
+
const channel = new PageChannel(driver, { windowId, ...(tab === undefined ? {} : { tab }), focused, base, transport, onRun: running => { driver.setRunning(running) }, ...(Number.isSafeInteger(shellWindow) && shellWindow > 0 ? { shellWindow } : {}) })
|
|
38
76
|
const reportFocus = (): void => { channel.reportFocus(focused()) }
|
|
39
77
|
view.addEventListener('focus', reportFocus)
|
|
40
78
|
view.addEventListener('blur', reportFocus)
|
|
@@ -54,6 +92,7 @@ export function startDoor(document: Document, base: string = DOOR_BASE): () => v
|
|
|
54
92
|
Reflect.deleteProperty(view, PAGE_KEY)
|
|
55
93
|
channel.dispose()
|
|
56
94
|
removeIndicator()
|
|
95
|
+
outlines.dispose()
|
|
57
96
|
detachInput()
|
|
58
97
|
// Pending calls settle as `unloaded` and every ref goes stale.
|
|
59
98
|
driver.dispose()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A green ring on the element the agent just used, for a moment (docs/design/blends-frame-mockup.html outlines the "My tasks" the assistant opened). It is
|
|
3
|
+
* drawn here, in the page itself, so the person sees what the agent touched in the frame's canvas and in production's assistant alike, and so it goes on its
|
|
4
|
+
* own: nothing outside the page has to draw it, position it, or take it away.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const STYLE_ID = 'acryl-door-outline-style'
|
|
8
|
+
/** The class the ring is drawn with: the page's element keeps its own styles, and an outline follows the element's shape. */
|
|
9
|
+
const CLASS = 'acrylDoorOutline'
|
|
10
|
+
/** How long the ring stays after the agent's last act; each act replaces it, so one run leaves one ring, a moment after its last step. */
|
|
11
|
+
export const OUTLINE_MS = 2000
|
|
12
|
+
|
|
13
|
+
const STYLES = `.${CLASS} { outline: 2px solid #3fb67b !important; outline-offset: 1px; }`
|
|
14
|
+
|
|
15
|
+
export interface Outlines {
|
|
16
|
+
/** Ring `element` as what the agent just used; the previous ring goes. */
|
|
17
|
+
touched(element: Element): void
|
|
18
|
+
/** Take the ring off and remove the style: nothing of this remains in the page. */
|
|
19
|
+
dispose(): void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Draw the ring in `document`. @returns the control the driver calls, and the disposer that leaves the page as it was. */
|
|
23
|
+
export function mountOutlines(document: Document): Outlines {
|
|
24
|
+
if (document.getElementById(STYLE_ID) === null) {
|
|
25
|
+
const style = document.createElement('style')
|
|
26
|
+
style.id = STYLE_ID
|
|
27
|
+
style.textContent = STYLES
|
|
28
|
+
document.head.append(style)
|
|
29
|
+
}
|
|
30
|
+
let ringed: Element | undefined
|
|
31
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
32
|
+
const clear = (): void => {
|
|
33
|
+
if (timer !== undefined) {
|
|
34
|
+
clearTimeout(timer)
|
|
35
|
+
timer = undefined
|
|
36
|
+
}
|
|
37
|
+
ringed?.classList.remove(CLASS)
|
|
38
|
+
ringed = undefined
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
touched(element: Element): void {
|
|
42
|
+
// The same control again in one run keeps its ring where it is; the person sees one ring, not a flicker.
|
|
43
|
+
if (element !== ringed) {
|
|
44
|
+
ringed?.classList.remove(CLASS)
|
|
45
|
+
ringed = element
|
|
46
|
+
element.classList.add(CLASS)
|
|
47
|
+
}
|
|
48
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
49
|
+
timer = setTimeout(clear, OUTLINE_MS)
|
|
50
|
+
},
|
|
51
|
+
dispose(): void {
|
|
52
|
+
clear()
|
|
53
|
+
document.getElementById(STYLE_ID)?.remove()
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -60,6 +60,8 @@ export interface UiNode {
|
|
|
60
60
|
readonly value?: string
|
|
61
61
|
/** Heading level, when it is a heading. */
|
|
62
62
|
readonly level?: number
|
|
63
|
+
/** The choices of a select, as `act select` takes them, when it is not sensitive. */
|
|
64
|
+
readonly options?: readonly string[]
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
export interface UiSnapshot {
|
|
@@ -88,6 +90,8 @@ export interface UiActionResult {
|
|
|
88
90
|
readonly ok: true
|
|
89
91
|
readonly target?: { readonly role: string; readonly name: string }
|
|
90
92
|
readonly detail?: string
|
|
93
|
+
/** The act started a navigation (a link, a form that submits): this page is going away, and the Host waits for the next one. */
|
|
94
|
+
readonly leaving?: true
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
export type UiResult = UiSnapshot | UiActionResult
|
|
@@ -11,7 +11,8 @@ export function renderSnapshot(snapshot: UiSnapshot, more: (cursor: number) => s
|
|
|
11
11
|
const level = node.level === undefined ? '' : ` level=${String(node.level)}`
|
|
12
12
|
const states = node.states.length === 0 ? '' : ` [${node.states.join(', ')}]`
|
|
13
13
|
const value = node.value === undefined ? '' : ` value=${JSON.stringify(node.value)}`
|
|
14
|
-
|
|
14
|
+
const options = node.options === undefined ? '' : ` options=${JSON.stringify(node.options)}`
|
|
15
|
+
return `${' '.repeat(Math.min(node.depth, 8))}- ${node.role}${node.name === '' ? '' : ` ${JSON.stringify(node.name)}`}${level}${states}${value}${options} [ref=${node.ref}]`
|
|
15
16
|
})
|
|
16
17
|
const rest = snapshot.nextCursor === undefined ? '' : `\n(${String(snapshot.total - snapshot.nextCursor)} more elements; ${more(snapshot.nextCursor)})`
|
|
17
18
|
return `Page: ${snapshot.title || '(untitled)'}\nSnapshot ${String(snapshot.generation)}, ${String(snapshot.nodes.length)} of ${String(snapshot.total)} elements:\n${lines.join('\n')}${rest}`
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from './request.ts'
|
|
15
15
|
import { defaultContentType, cacheKey, Response as LightweightResponse } from './response.ts'
|
|
16
16
|
import type { InternalCache } from './response.ts'
|
|
17
|
+
import { RAW_RESPONSE_HANDLED } from './raw.ts'
|
|
17
18
|
import type { CustomErrorHandler, FetchCallback, HttpBindings } from './types.ts'
|
|
18
19
|
import {
|
|
19
20
|
readWithoutBlocking,
|
|
@@ -500,6 +501,9 @@ export const getRequestListener = (
|
|
|
500
501
|
res = fetchCallback(req, { incoming, outgoing } as HttpBindings) as
|
|
501
502
|
| Response
|
|
502
503
|
| Promise<Response>
|
|
504
|
+
| typeof RAW_RESPONSE_HANDLED
|
|
505
|
+
// A raw handler (not a `Response`) owns the node response: it has written (or is writing) it, so the adapter writes nothing.
|
|
506
|
+
if (res === RAW_RESPONSE_HANDLED) return
|
|
503
507
|
if (!isPromise(res) && isImmediateCacheableResponse(res)) {
|
|
504
508
|
// Synchronous cacheable response — no close listener needed.
|
|
505
509
|
// No I/O events can fire between fetchCallback returning and responseViaCache
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw HTTP handlers, outside the router: a host that must serve another server's node `(req, res)` handlers (a DSH compatibility port, the
|
|
3
|
+
* desktop shell's own endpoints) registers by path instead of building an `Acryl`. The adapter hands a matched request to the handler with the
|
|
4
|
+
* real `IncomingMessage`/`ServerResponse` and writes nothing itself; `RAW_RESPONSE_HANDLED` is how the fetch callback tells it so.
|
|
5
|
+
*
|
|
6
|
+
* Upgrades work the same way at the socket level: a registered path is claimed before the router's WebSocket handling, and the adapter's
|
|
7
|
+
* WebSocket setup leaves a request marked with `RAW_UPGRADE_HANDLED` alone.
|
|
8
|
+
*/
|
|
9
|
+
import type { IncomingMessage } from 'node:http'
|
|
10
|
+
|
|
11
|
+
/** Returned by a raw-aware fetch callback: the node response is already owned, do not write. */
|
|
12
|
+
export const RAW_RESPONSE_HANDLED: unique symbol = Symbol.for('acryl-node-server.raw-response')
|
|
13
|
+
|
|
14
|
+
/** Set on an upgrade request a raw handler took over; the adapter's WebSocket setup skips it. */
|
|
15
|
+
export const RAW_UPGRADE_HANDLED: unique symbol = Symbol.for('acryl-node-server.raw-upgrade')
|
|
16
|
+
|
|
17
|
+
/** An upgrade request a raw handler took over. */
|
|
18
|
+
export type RawUpgradeRequest = IncomingMessage & { [RAW_UPGRADE_HANDLED]?: true }
|
|
@@ -3,6 +3,7 @@ import { defineWebSocketHelper } from 'acryl-router/helpers'
|
|
|
3
3
|
import type { IncomingMessage } from 'node:http'
|
|
4
4
|
import { STATUS_CODES } from 'node:http'
|
|
5
5
|
import type { Duplex } from 'node:stream'
|
|
6
|
+
import { RAW_UPGRADE_HANDLED, type RawUpgradeRequest } from './raw.ts'
|
|
6
7
|
import type { FetchCallback } from './types.ts'
|
|
7
8
|
import type { WebSocketData, WebSocketLike, WebSocketServerLike } from './websocket-types.ts'
|
|
8
9
|
|
|
@@ -144,6 +145,8 @@ const appendResponseHeaders = (headers: string[], responseHeaders?: Headers) =>
|
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
const rejectUpgradeRequest = (socket: Duplex, status: number, responseHeaders?: Headers) => {
|
|
148
|
+
// The socket already has its answer (a provider transition can route one upgrade to two handlers for a moment): never write a second one.
|
|
149
|
+
if (socket.destroyed || socket.writableEnded) return
|
|
147
150
|
const responseLines = ['Connection: close', 'Content-Length: 0']
|
|
148
151
|
appendResponseHeaders(responseLines, responseHeaders)
|
|
149
152
|
|
|
@@ -181,8 +184,13 @@ export const setupWebSocket = (options: {
|
|
|
181
184
|
server: UpgradeSource
|
|
182
185
|
fetchCallback: FetchCallback
|
|
183
186
|
wss: WebSocketServerLike
|
|
187
|
+
/**
|
|
188
|
+
* Leave a request alone when a raw upgrade handler claimed it. Set by a transport that shares its upgrade source with raw claims (the node
|
|
189
|
+
* server's HTTP server); a hub fed by its own emitter (the Web adapter) must still answer its own upgrades, which arrive through a raw claim.
|
|
190
|
+
*/
|
|
191
|
+
skipWhenRawUpgrade?: boolean
|
|
184
192
|
}): void => {
|
|
185
|
-
const { server, fetchCallback, wss } = options
|
|
193
|
+
const { server, fetchCallback, wss, skipWhenRawUpgrade = false } = options
|
|
186
194
|
|
|
187
195
|
const waiterMap = new Map<
|
|
188
196
|
IncomingMessage,
|
|
@@ -216,9 +224,11 @@ export const setupWebSocket = (options: {
|
|
|
216
224
|
}
|
|
217
225
|
|
|
218
226
|
server.on('upgrade', async (request, socket: Duplex, head) => {
|
|
227
|
+
// A raw upgrade handler claimed this path before the WebSocket setup ran: the socket is (or will be) its own.
|
|
228
|
+
if (skipWhenRawUpgrade && (request as RawUpgradeRequest)[RAW_UPGRADE_HANDLED] === true) return
|
|
219
229
|
if (request.headers.upgrade?.toLowerCase() !== 'websocket') {
|
|
220
|
-
//
|
|
221
|
-
|
|
230
|
+
// An upgrade to another protocol is answered, not left hanging (the transport's raw listener flags the ones it already answered).
|
|
231
|
+
rejectUpgradeRequest(socket, 400)
|
|
222
232
|
return
|
|
223
233
|
}
|
|
224
234
|
|
|
@@ -241,9 +251,7 @@ export const setupWebSocket = (options: {
|
|
|
241
251
|
responseHeaders = response.headers
|
|
242
252
|
}
|
|
243
253
|
} catch {
|
|
244
|
-
|
|
245
|
-
rejectUpgradeRequest(socket, 500)
|
|
246
|
-
}
|
|
254
|
+
rejectUpgradeRequest(socket, 500)
|
|
247
255
|
return
|
|
248
256
|
}
|
|
249
257
|
|
|
@@ -251,9 +259,7 @@ export const setupWebSocket = (options: {
|
|
|
251
259
|
|
|
252
260
|
if (!waiter || waiter.connectionSymbol !== env[CONNECTION_SYMBOL_KEY]) {
|
|
253
261
|
rejectWaiter(request)
|
|
254
|
-
|
|
255
|
-
rejectUpgradeRequest(socket, status, responseHeaders)
|
|
256
|
-
}
|
|
262
|
+
rejectUpgradeRequest(socket, status, responseHeaders)
|
|
257
263
|
return
|
|
258
264
|
}
|
|
259
265
|
|
|
@@ -38,6 +38,12 @@ const DEFAULT_CACHE_BYTES = 16 * 1024 * 1024
|
|
|
38
38
|
export interface ClientModulesOptions {
|
|
39
39
|
/** The folder of client modules, absolute or relative to the working directory. */
|
|
40
40
|
readonly root: string
|
|
41
|
+
/**
|
|
42
|
+
* The URL prefix the app is mounted at, when the caller knows it. A host can compose app plugins under its own router, and under a
|
|
43
|
+
* Host whose router is a different copy of this package the request's match result is not visible here, so `basePath(c)` throws; the
|
|
44
|
+
* explicit prefix is then the only reliable way to know where the mount starts. Omitted, the router's `basePath(c)` is used.
|
|
45
|
+
*/
|
|
46
|
+
readonly mount?: string | undefined
|
|
41
47
|
/** The folder {@link buildClientModules} wrote; with `NODE_ENV=production` it is served instead of compiling, which needs no `typescript`. */
|
|
42
48
|
readonly compiled?: string | undefined
|
|
43
49
|
/** At most this many bytes of compiled modules are kept in memory (least recently served first out); 16 MiB when not given. */
|
|
@@ -204,7 +210,7 @@ export function clientModules(options: ClientModulesOptions): Acryl {
|
|
|
204
210
|
|
|
205
211
|
const app = new Acryl()
|
|
206
212
|
app.get('/*', (c) => {
|
|
207
|
-
const requestPath = c.req.path.slice(basePath(c).length)
|
|
213
|
+
const requestPath = c.req.path.slice(options.mount === undefined ? basePath(c).length : options.mount.length)
|
|
208
214
|
const runtime = /^\/@acrylblends\/([a-z-]+\.js)$/u.exec(requestPath)?.[1]
|
|
209
215
|
const send = (code: string, etag: string): Response => {
|
|
210
216
|
if (c.req.header('if-none-match') === etag) return c.body(null, 304, { etag, 'cache-control': 'no-cache' })
|