acrylblends 1.3.2 → 1.4.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/dist/index.d.ts +1 -1
- package/dist/index.js +14 -5
- package/dist/index.js.map +1 -1
- package/legacy/README.md +1 -1
- package/legacy/layouts.json +11 -0
- package/lib/cordis/door.d.ts +27 -6
- package/lib/cordis/door.js +40 -9
- package/package.json +1 -1
- package/plugins/acryl-door/src/cli/acryl.ts +7 -2
- package/plugins/acryl-door/src/host/door.ts +41 -3
- package/plugins/acryl-door/src/host/verbs.ts +3 -3
- package/plugins/extensions/index.ts +12 -6
- package/plugins/plugin-share/index.ts +99 -1
- package/plugins/plugin-share/publish.ts +17 -3
- package/plugins/plugin-share/pull-check.ts +2 -0
- package/plugins/plugin-share/pull-list.ts +71 -0
- package/plugins/showcase/cards.ts +162 -0
- package/plugins/showcase/index.ts +116 -0
- package/plugins/showcase/plugin.json +11 -0
- package/templates/agent/AGENTS.md +4 -0
- package/templates/agent/blend.yaml +3 -0
- package/templates/agent/src/app.ts +2 -0
- package/templates/agent/src/routes.ts +23 -5
- package/templates/agent/template.json +1 -0
- package/templates/agent/test/app.test.ts +26 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The showcase's cards: what each plugin in the app is, who made it and where to find what it adds. Pure, so the page, the stream and the tests
|
|
3
|
+
* render the same thing.
|
|
4
|
+
*/
|
|
5
|
+
import type { SharedPluginInfo } from '../plugin-share/index.ts'
|
|
6
|
+
|
|
7
|
+
/** One plugin as the showcase shows it. */
|
|
8
|
+
export interface ShowcaseCard {
|
|
9
|
+
readonly name: string
|
|
10
|
+
/** The release's version, or the installed content's short digest for a plugin without one. */
|
|
11
|
+
readonly version: string
|
|
12
|
+
readonly state: string
|
|
13
|
+
/** The GitHub login of whoever released it; absent for a plugin made in this app or pulled from a local file. */
|
|
14
|
+
readonly author?: string
|
|
15
|
+
/** Where it came from: the pulled source, or `this app`. */
|
|
16
|
+
readonly source: string
|
|
17
|
+
/** The pages it serves (`GET` routes without parameters), as the plugin wrote them. */
|
|
18
|
+
readonly pages: readonly string[]
|
|
19
|
+
/** It has a widget on the home page. */
|
|
20
|
+
readonly widget: boolean
|
|
21
|
+
/** The tool of its name, when it registered one. */
|
|
22
|
+
readonly tool?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What else the app knows about its plugins: the tools registered and the home page's widgets, by name. */
|
|
26
|
+
export interface Offered {
|
|
27
|
+
readonly tools: readonly string[]
|
|
28
|
+
readonly widgets: readonly string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A GitHub login: what `author` must look like before the page links to github.com. */
|
|
32
|
+
const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/u
|
|
33
|
+
|
|
34
|
+
/** A path a person can open: no `:param`, no wildcard. */
|
|
35
|
+
const PAGE = /^\/[^:*]*$/u
|
|
36
|
+
|
|
37
|
+
/** The cards, one per plugin, in the order the app lists them. */
|
|
38
|
+
export function showcaseCards(
|
|
39
|
+
plugins: readonly SharedPluginInfo[],
|
|
40
|
+
offered: Offered,
|
|
41
|
+
): ShowcaseCard[] {
|
|
42
|
+
return plugins.map((plugin) => {
|
|
43
|
+
const pages = [
|
|
44
|
+
...new Set(
|
|
45
|
+
plugin.routes
|
|
46
|
+
.filter((route) => route.method === 'GET' && PAGE.test(route.path))
|
|
47
|
+
.map((route) => route.path),
|
|
48
|
+
),
|
|
49
|
+
]
|
|
50
|
+
return {
|
|
51
|
+
name: plugin.name,
|
|
52
|
+
version: plugin.release ?? plugin.version,
|
|
53
|
+
state: plugin.state,
|
|
54
|
+
...(plugin.author !== undefined && LOGIN.test(plugin.author)
|
|
55
|
+
? { author: plugin.author }
|
|
56
|
+
: {}),
|
|
57
|
+
source: plugin.pulledFrom ?? 'this app',
|
|
58
|
+
pages,
|
|
59
|
+
widget: offered.widgets.includes(plugin.name),
|
|
60
|
+
...(offered.tools.includes(plugin.name) ? { tool: plugin.name } : {}),
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const escapeHtml = (text: string): string =>
|
|
66
|
+
text
|
|
67
|
+
.replaceAll('&', '&')
|
|
68
|
+
.replaceAll('<', '<')
|
|
69
|
+
.replaceAll('>', '>')
|
|
70
|
+
.replaceAll('"', '"')
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The path the app is served at, read from the showcase's own request path: `''` on the app's own server, `/app` in the frame. Links are built from it,
|
|
74
|
+
* because a relative link cannot name the app's root (`./` from `/app/showcase` is `/app/`, which the app does not serve).
|
|
75
|
+
*/
|
|
76
|
+
export function appBase(requestPath: string): string {
|
|
77
|
+
const at = requestPath.lastIndexOf('/showcase')
|
|
78
|
+
return at <= 0 ? '' : requestPath.slice(0, at)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A path of the app as the browser asks for it: under `base`, and the root as `base` itself (or `/`). */
|
|
82
|
+
const inApp = (base: string, path: string): string =>
|
|
83
|
+
path === '/' ? base || '/' : `${base}${path}`
|
|
84
|
+
|
|
85
|
+
/** The cards as HTML: the list the page shows, and what the stream sends on each change. Every value is escaped. */
|
|
86
|
+
export function renderCards(cards: readonly ShowcaseCard[], base = ''): string {
|
|
87
|
+
if (cards.length === 0)
|
|
88
|
+
return '<p class="empty">No plugins yet. Pull one: <code>npm run acryl -- plugin pull github:<owner>/<repo></code>, or a group\'s list with <code>--from group.txt</code>.</p>'
|
|
89
|
+
return cards
|
|
90
|
+
.map((card) => {
|
|
91
|
+
const author =
|
|
92
|
+
card.author === undefined
|
|
93
|
+
? '<span class="dim">made here</span>'
|
|
94
|
+
: `<a href="https://github.com/${escapeHtml(card.author)}" target="_blank" rel="noopener">@${escapeHtml(card.author)}</a>`
|
|
95
|
+
const links = [
|
|
96
|
+
...card.pages.map(
|
|
97
|
+
(page) => `<a href="${escapeHtml(inApp(base, page))}">${escapeHtml(page)}</a>`,
|
|
98
|
+
),
|
|
99
|
+
...(card.widget ? [`<a href="${escapeHtml(inApp(base, '/'))}">home widget</a>`] : []),
|
|
100
|
+
...(card.tool === undefined
|
|
101
|
+
? []
|
|
102
|
+
: [
|
|
103
|
+
`<span title="the app's agent can call it">tool <code>${escapeHtml(card.tool)}</code></span>`,
|
|
104
|
+
]),
|
|
105
|
+
]
|
|
106
|
+
return [
|
|
107
|
+
`<article class="card" data-plugin="${escapeHtml(card.name)}" data-state="${escapeHtml(card.state)}">`,
|
|
108
|
+
`<header><h2>${escapeHtml(card.name)}</h2><p class="meta"><span class="version">${escapeHtml(card.version)}</span><span class="state state-${escapeHtml(card.state)}">${escapeHtml(card.state)}</span></p></header>`,
|
|
109
|
+
`<p class="author">${author}</p>`,
|
|
110
|
+
`<p class="source"><code>${escapeHtml(card.source)}</code></p>`,
|
|
111
|
+
links.length === 0
|
|
112
|
+
? '<p class="dim">no page of its own</p>'
|
|
113
|
+
: `<p class="links">${links.join(' ')}</p>`,
|
|
114
|
+
'</article>',
|
|
115
|
+
].join('')
|
|
116
|
+
})
|
|
117
|
+
.join('\n')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const STYLES = `:root{--bg:#fff;--surface:#f7f7f9;--border:#e2e2e8;--text:#18181b;--dim:#6b6b75;--accent:#5468ff;--ok:#2f9e44;--warn:#d9480f;color-scheme:light}
|
|
121
|
+
@media (prefers-color-scheme:dark){:root{--bg:#0b0b0d;--surface:#18181b;--border:#2a2a2f;--text:#e8e8ea;--dim:#8b8b93;color-scheme:dark}}
|
|
122
|
+
*{box-sizing:border-box}
|
|
123
|
+
body{margin:0;padding:24px 16px;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
124
|
+
main{max-width:1080px;margin:0 auto}
|
|
125
|
+
h1{margin:0 0 4px;font-size:20px}
|
|
126
|
+
.lead{margin:0 0 20px;color:var(--dim)}
|
|
127
|
+
#cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px}
|
|
128
|
+
.card{padding:14px 16px;border:1px solid var(--border);border-radius:12px;background:var(--surface);min-width:0}
|
|
129
|
+
.card header{margin-bottom:8px}
|
|
130
|
+
.card h2{margin:0 0 4px;font-size:15px;font-weight:600;overflow-wrap:anywhere}
|
|
131
|
+
.card .meta{display:flex;align-items:center;gap:8px;margin:0}
|
|
132
|
+
.version{color:var(--dim);font-variant-numeric:tabular-nums}
|
|
133
|
+
.state{padding:1px 8px;border-radius:999px;border:1px solid var(--border);font-size:12px;color:var(--dim)}
|
|
134
|
+
.state-active{color:var(--ok);border-color:currentColor}
|
|
135
|
+
.state-failed,.state-pending{color:var(--warn);border-color:currentColor}
|
|
136
|
+
.card p{margin:4px 0}
|
|
137
|
+
.source code{font-size:12px;color:var(--dim);overflow-wrap:anywhere}
|
|
138
|
+
.links{display:flex;flex-wrap:wrap;gap:6px 12px}
|
|
139
|
+
a{color:var(--accent);text-decoration:none}
|
|
140
|
+
a:hover{text-decoration:underline}
|
|
141
|
+
.dim,.empty{color:var(--dim)}
|
|
142
|
+
#live{font-size:12px;color:var(--dim)}`
|
|
143
|
+
|
|
144
|
+
/** The whole page; the script follows the stream and replaces the cards on each change. */
|
|
145
|
+
export function renderPage(cards: readonly ShowcaseCard[], base = ''): string {
|
|
146
|
+
return `<!doctype html>
|
|
147
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
148
|
+
<title>Showcase</title><style>${STYLES}</style></head>
|
|
149
|
+
<body><main>
|
|
150
|
+
<h1>Showcase</h1>
|
|
151
|
+
<p class="lead">Every plugin in this app, who made it and where it came from. <span id="live" role="status"></span></p>
|
|
152
|
+
<section id="cards" aria-live="polite">${renderCards(cards, base)}</section>
|
|
153
|
+
</main>
|
|
154
|
+
<script type="module">
|
|
155
|
+
const cards = document.getElementById('cards')
|
|
156
|
+
const live = document.getElementById('live')
|
|
157
|
+
const events = new EventSource(${JSON.stringify(`${base}/showcase/events`).replaceAll('<', '\\u003c')})
|
|
158
|
+
events.addEventListener('cards', (event) => { cards.innerHTML = event.data; live.textContent = 'live' })
|
|
159
|
+
events.onerror = () => { live.textContent = 'reconnecting...' }
|
|
160
|
+
</script>
|
|
161
|
+
</body></html>`
|
|
162
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The collective app's showcase: every plugin in the app as a card (name, author, version, source, state, links to what it adds) at `/showcase`,
|
|
3
|
+
* updated live as plugins are pulled and removed.
|
|
4
|
+
*
|
|
5
|
+
* A group pulls its members' plugins into one app (`plugin pull --from group.txt`); this page is where they see what the group made. It reads the
|
|
6
|
+
* app's plugins through `pluginShare` (which knows where each pulled one came from), and follows `extensions/changed`: every install, update,
|
|
7
|
+
* restore and removal goes through extensions, so one event covers them all. The stream's open connections end with the plugin.
|
|
8
|
+
*/
|
|
9
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
10
|
+
import { streamSSE } from '../acryl-router/src/helpers/index.ts'
|
|
11
|
+
import { Acryl } from '../acryl-router/src/index.ts'
|
|
12
|
+
import type {} from '../extensions/index.ts'
|
|
13
|
+
import type {} from '../plugin-share/index.ts'
|
|
14
|
+
import {
|
|
15
|
+
appBase,
|
|
16
|
+
type Offered,
|
|
17
|
+
renderCards,
|
|
18
|
+
renderPage,
|
|
19
|
+
type ShowcaseCard,
|
|
20
|
+
showcaseCards,
|
|
21
|
+
} from './cards.ts'
|
|
22
|
+
|
|
23
|
+
export type { ShowcaseCard } from './cards.ts'
|
|
24
|
+
export { appBase, renderCards, showcaseCards } from './cards.ts'
|
|
25
|
+
|
|
26
|
+
export const name = 'showcase'
|
|
27
|
+
export const inject = ['router', 'pluginShare']
|
|
28
|
+
|
|
29
|
+
/** A comment line this often keeps an idle stream open through proxies and browsers. */
|
|
30
|
+
const KEEP_ALIVE_MS = 15_000
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The names an optional service lists, or none when it is absent or is not the service the showcase knows. A name can be someone else's: in the
|
|
34
|
+
* frame `tools` is the host's (the DSH harness's tool registry, with another shape), not the app's agent tools, so the shape is checked, not assumed.
|
|
35
|
+
*/
|
|
36
|
+
function namesOf(service: unknown, method: string, key: string): string[] {
|
|
37
|
+
const list = (service as Record<string, unknown> | undefined)?.[method]
|
|
38
|
+
if (typeof list !== 'function') return []
|
|
39
|
+
let entries: unknown
|
|
40
|
+
try {
|
|
41
|
+
entries = (list as () => unknown).call(service)
|
|
42
|
+
} catch {
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
45
|
+
if (!Array.isArray(entries)) return []
|
|
46
|
+
return entries.flatMap((entry) => {
|
|
47
|
+
const value = (entry as Record<string, unknown> | null)?.[key]
|
|
48
|
+
return typeof value === 'string' ? [value] : []
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The optional services the cards link to, read where they are now: an app without an agent has no tools, one without a home page no widgets. */
|
|
53
|
+
function offered(ctx: Context): Offered {
|
|
54
|
+
const get = ctx.get as (service: string) => unknown
|
|
55
|
+
return {
|
|
56
|
+
tools: namesOf(get('tools'), 'list', 'name'),
|
|
57
|
+
widgets: namesOf(get('home'), 'widgets', 'id'),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function apply(ctx: Context): void {
|
|
62
|
+
const cards = (): ShowcaseCard[] => showcaseCards(ctx.pluginShare.list(), offered(ctx))
|
|
63
|
+
|
|
64
|
+
// The pages that follow the stream; each gets the cards again after every change, rendered for the path it is served at.
|
|
65
|
+
const listeners = new Set<(now: readonly ShowcaseCard[]) => void>()
|
|
66
|
+
const ends = new Set<() => void>()
|
|
67
|
+
ctx.on('extensions/changed', () => {
|
|
68
|
+
if (listeners.size === 0) return
|
|
69
|
+
const now = cards()
|
|
70
|
+
for (const listener of listeners) listener(now)
|
|
71
|
+
})
|
|
72
|
+
ctx.effect(
|
|
73
|
+
() => () => {
|
|
74
|
+
for (const end of ends) end()
|
|
75
|
+
},
|
|
76
|
+
'showcase: end the open streams',
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const app = new Acryl()
|
|
80
|
+
.get('/showcase', (c) =>
|
|
81
|
+
c.html(renderPage(cards(), appBase(c.req.path)), 200, { 'cache-control': 'no-store' }),
|
|
82
|
+
)
|
|
83
|
+
.get('/showcase/plugins', (c) => c.json(cards(), 200, { 'cache-control': 'no-store' }))
|
|
84
|
+
.get('/showcase/events', (c) =>
|
|
85
|
+
streamSSE(c, async (stream) => {
|
|
86
|
+
const base = appBase(c.req.path)
|
|
87
|
+
let queue: Promise<unknown> = Promise.resolve()
|
|
88
|
+
const send = (now: readonly ShowcaseCard[]): void => {
|
|
89
|
+
queue = queue
|
|
90
|
+
.then(() => stream.writeSSE({ event: 'cards', data: renderCards(now, base) }))
|
|
91
|
+
.catch(() => undefined)
|
|
92
|
+
}
|
|
93
|
+
let finish = (): void => undefined
|
|
94
|
+
const ended = new Promise<void>((resolve) => {
|
|
95
|
+
finish = resolve
|
|
96
|
+
stream.onAbort(resolve)
|
|
97
|
+
})
|
|
98
|
+
ends.add(finish)
|
|
99
|
+
listeners.add(send)
|
|
100
|
+
send(cards())
|
|
101
|
+
const keepAlive = setInterval(() => {
|
|
102
|
+
queue = queue.then(() => stream.write(': keepalive\n\n')).catch(() => undefined)
|
|
103
|
+
}, KEEP_ALIVE_MS)
|
|
104
|
+
keepAlive.unref()
|
|
105
|
+
try {
|
|
106
|
+
await ended
|
|
107
|
+
} finally {
|
|
108
|
+
clearInterval(keepAlive)
|
|
109
|
+
listeners.delete(send)
|
|
110
|
+
ends.delete(finish)
|
|
111
|
+
await queue
|
|
112
|
+
}
|
|
113
|
+
}),
|
|
114
|
+
)
|
|
115
|
+
ctx.router.route('/', app)
|
|
116
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "showcase",
|
|
3
|
+
"description": "The collective app's showcase: a page at /showcase with a card for every plugin in the app (name, author, version, where it came from, its state, and links to its pages, its home widget and its tool), updated live as plugins are pulled and removed.",
|
|
4
|
+
"requires": ["acryl-router", "plugin-share", "extensions"],
|
|
5
|
+
"inject": ["router", "pluginShare"],
|
|
6
|
+
"provides": [],
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@deepseek-ai/cordis": "4.0.4"
|
|
9
|
+
},
|
|
10
|
+
"mount": "a row `- id: showcase` / `name: showcase` after the plugin-share row in blend.yaml, and 'showcase': showcase in the registry of src/app.ts (acrylblends add showcase does both)"
|
|
11
|
+
}
|
|
@@ -5,6 +5,10 @@ Besides `src/routes.ts`, the app runs an agent (`POST /ask`) that can write plug
|
|
|
5
5
|
rollback; `GET /extensions` and `GET /tasks` show what it did. A plugin there is a folder with an `index.ts` exporting `name`, `inject`, `apply(ctx)`
|
|
6
6
|
and an optional `check(ctx)` that receives the plugin's own context (so `ctx.router` reads as in `apply`). It may import `acryl-router`.
|
|
7
7
|
|
|
8
|
+
**A plugin's UI on the home page.** The home page shows the widget of every plugin that registers one (the `home` row): `inject: ['home']` and
|
|
9
|
+
`ctx.effect(() => ctx.home.widget({ id: '<plugin name>', title, render: () => '<p>...</p>' }))`. The widget leaves with its plugin, and a second
|
|
10
|
+
plugin with the same widget id is refused naming the first. A widget is the way a plugin someone else pulls into this app shows up on the first screen.
|
|
11
|
+
|
|
8
12
|
**Write an extension, not a row.** A feature you build while the app runs is a folder `plugins/<name>/` installed with `.acryl/bin/acryl plugin add plugins/<name>` (the `extensions` plugin loads it again at every start). Add a row to `blend.yaml` (`- id: <name>` / `name: <name>`; `src/app.ts` finds the folder by that name) only when the person asks for a plugin that is part of the app itself. Both kinds may import the framework by name (`acrylblends`, `acrylblends/body-limit`, `acryl-router`); a row's relative imports of its sibling plugins also work.
|
|
9
13
|
|
|
10
14
|
**Drive the app you build.** `.acryl/bin/acryl look` reads the open page as text with refs, and `acryl act click|type|press|scroll|wait --ref <ref>` presses its buttons, opens its sidebars and fills its forms (look again before each act: the refs change). `act navigate --path /settings` goes to another page of the app; a path the app does not serve answers `404 at /nope` and the window shows a 404 page, so navigate on from there. In the desktop app (`npm run desktop`) the app's windows and menu are verbs too: `acryl window list`, `acryl window open --path /settings`, `acryl look --window <id>` (and `act ... --window <id>`) to drive that window's page, `acryl window resize|focus|close --id <id>`, `acryl menu list` and `acryl menu click --path "File>New Note"`. Closing the last window and Quit end the desktop app, so they need `--force`: do not use it unless the person asks. The app has a `/settings` page already (the appearance: system, light or dark, kept in the browser) and the desktop app's menu has a Settings item that opens it; add a person's other settings to that page, not to a new one, so `acryl menu click --path "File>Settings..."` and `acryl window open --path /settings` keep opening them (on macOS the item is in the application menu). Without a desktop app these answer "no desktop window is attached; run npm run desktop".
|
|
@@ -39,6 +39,9 @@ spec:
|
|
|
39
39
|
steps: "{{parameters.steps}}"
|
|
40
40
|
- id: router
|
|
41
41
|
name: acryl-router
|
|
42
|
+
# The home page's widgets: a plugin puts its UI on the home page with ctx.home.widget (one pulled from someone else's app too), and it leaves with the plugin.
|
|
43
|
+
- id: home
|
|
44
|
+
name: home
|
|
42
45
|
- id: state
|
|
43
46
|
name: acryl-state
|
|
44
47
|
- id: sandbox
|
|
@@ -10,6 +10,7 @@ import { agent, memory, model, scripted, tools } from '../plugins/agent/index.ts
|
|
|
10
10
|
import * as panel from '../plugins/agent-panel/index.ts'
|
|
11
11
|
import { builder } from '../plugins/builder/index.ts'
|
|
12
12
|
import { extensions } from '../plugins/extensions/index.ts'
|
|
13
|
+
import * as home from '../plugins/home/index.ts'
|
|
13
14
|
import { type AppInstance, instance } from '../plugins/instance/index.ts'
|
|
14
15
|
import {
|
|
15
16
|
appPluginNames,
|
|
@@ -34,6 +35,7 @@ export const registry = {
|
|
|
34
35
|
memory,
|
|
35
36
|
agent,
|
|
36
37
|
'acryl-router': router,
|
|
38
|
+
home,
|
|
37
39
|
'acryl-state': state,
|
|
38
40
|
extensions,
|
|
39
41
|
builder,
|
|
@@ -3,6 +3,7 @@ import { Acryl } from '../plugins/acryl-router/src/index.ts'
|
|
|
3
3
|
import { bodyLimit } from '../plugins/acryl-router/src/middleware/index.ts'
|
|
4
4
|
import type {} from '../plugins/agent/index.ts'
|
|
5
5
|
import type {} from '../plugins/extensions/index.ts'
|
|
6
|
+
import { type Home, renderWidgets } from '../plugins/home/index.ts'
|
|
6
7
|
import type {} from '../plugins/tasks/index.ts'
|
|
7
8
|
|
|
8
9
|
export const APP_NAME = '__APP_NAME__'
|
|
@@ -59,20 +60,24 @@ const appearanceScript = (prefix: string) => `<script src="${prefix}/appearance.
|
|
|
59
60
|
const settingsPage = (prefix: string) =>
|
|
60
61
|
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Settings - ${APP_NAME}</title>${APPEARANCE_STYLE}${appearanceScript(prefix)}</head><body><h1>Settings</h1><p><label for="theme">Appearance</label> <select id="theme" name="theme"><option value="system">Match the system</option><option value="light">Light</option><option value="dark">Dark</option></select></p><p id="saved" role="status"></p><p><a href="${prefix}/">Back to ${APP_NAME}</a></p><script>(function(){var select=document.getElementById('theme'),saved=document.getElementById('saved');try{select.value=localStorage.getItem('theme')||'system'}catch(e){}select.addEventListener('change',function(){var value=select.value;try{if(value==='system'){localStorage.removeItem('theme');delete document.documentElement.dataset.theme}else{localStorage.setItem('theme',value);document.documentElement.dataset.theme=value}saved.textContent='Saved: '+select.options[select.selectedIndex].text}catch(e){saved.textContent='This browser would not keep the setting.'}})})()</script></body></html>`
|
|
61
62
|
|
|
63
|
+
const widgetsOf = (home: Home | undefined): Promise<string> =>
|
|
64
|
+
home === undefined ? Promise.resolve('') : renderWidgets(home)
|
|
65
|
+
|
|
62
66
|
/**
|
|
63
67
|
* The app's own pages and routes, mountable at any prefix: the standalone server mounts it at the root, the frame row at {@link FRAME_MOUNT_PREFIX}.
|
|
64
|
-
* Every absolute URL a page writes is derived from the prefix it is mounted at.
|
|
68
|
+
* Every absolute URL a page writes is derived from the prefix it is mounted at. The home page shows the widgets of the app's plugins (`ctx.home`), read
|
|
69
|
+
* at each request through `home()`, so a plugin pulled, switched off or removed shows at the next load; without a home service the page has none.
|
|
65
70
|
*/
|
|
66
|
-
export function makeApp(prefix = ''): Acryl {
|
|
71
|
+
export function makeApp(prefix = '', home: () => Home | undefined = () => undefined): Acryl {
|
|
67
72
|
const limit = bodyLimit({ maxSize: 64 * 1024 })
|
|
68
73
|
return (
|
|
69
74
|
new Acryl()
|
|
70
75
|
.use(limit)
|
|
71
76
|
// A browser gets a page (the door's and the agent panel's script tags are added where they are mounted); everything else gets the JSON.
|
|
72
|
-
.get('/', (c) =>
|
|
77
|
+
.get('/', async (c) =>
|
|
73
78
|
(c.req.header('accept') ?? '').includes('text/html')
|
|
74
79
|
? c.html(
|
|
75
|
-
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title>${APPEARANCE_STYLE}${appearanceScript(prefix)}</head><body><h1>${APP_NAME}</h1><p>This app runs an agent unless it is switched off (production switches it off, see ACRYL_PRODUCTION_KEEP in AGENTS.md). In ACRYL, ask it for a change in the chat beside the app (or with <code>POST /ask</code>); published with the assistant on, it helps visitors use the app without changing it.</p>${DOOR_TAG}</body></html>`,
|
|
80
|
+
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title>${APPEARANCE_STYLE}${appearanceScript(prefix)}</head><body><h1>${APP_NAME}</h1><p>This app runs an agent unless it is switched off (production switches it off, see ACRYL_PRODUCTION_KEEP in AGENTS.md). In ACRYL, ask it for a change in the chat beside the app (or with <code>POST /ask</code>); published with the assistant on, it helps visitors use the app without changing it.</p>${await widgetsOf(home())}${DOOR_TAG}</body></html>`,
|
|
76
81
|
)
|
|
77
82
|
: c.json({ app: APP_NAME, ok: true }),
|
|
78
83
|
)
|
|
@@ -102,7 +107,20 @@ export const routes = {
|
|
|
102
107
|
}
|
|
103
108
|
const limit = bodyLimit({ maxSize: 64 * 1024 })
|
|
104
109
|
// The app's own page and routes at the one prefix; the agent routes below mount at the same prefix.
|
|
105
|
-
|
|
110
|
+
// The home page's widgets while a `home` row runs: an app made before the agent template mounted `home` (its blend.yaml has no such row) keeps its pages.
|
|
111
|
+
let home: Home | undefined
|
|
112
|
+
ctx.inject(['home'], (inner) => {
|
|
113
|
+
inner.effect(() => {
|
|
114
|
+
home = inner.home
|
|
115
|
+
return () => {
|
|
116
|
+
home = undefined
|
|
117
|
+
}
|
|
118
|
+
}, 'routes: the home page widgets')
|
|
119
|
+
})
|
|
120
|
+
ctx.router.route(
|
|
121
|
+
prefix === '' ? '/' : prefix,
|
|
122
|
+
makeApp(prefix, () => home),
|
|
123
|
+
)
|
|
106
124
|
// The routes that give a visitor the agent: mounted only while the agent, the extensions and the tasks run (see the header).
|
|
107
125
|
// `agentRoutes: false` (the production Assistant) keeps them unmounted although the agent runs.
|
|
108
126
|
if (options.agentRoutes === false) return
|
|
@@ -44,6 +44,32 @@ test('answers health checks and 404 for unknown paths', () =>
|
|
|
44
44
|
await missing.body?.cancel()
|
|
45
45
|
}))
|
|
46
46
|
|
|
47
|
+
test('a plugin puts its widget on the home page, and the widget leaves with the plugin', () =>
|
|
48
|
+
withApp(async (app) => {
|
|
49
|
+
const page = async () =>
|
|
50
|
+
(await app.root.router.request('/', { headers: { accept: 'text/html' } })).text()
|
|
51
|
+
const feature = app.root.plugin({
|
|
52
|
+
name: 'hello-widget',
|
|
53
|
+
inject: ['home'],
|
|
54
|
+
apply(ctx: typeof app.root) {
|
|
55
|
+
ctx.effect(() =>
|
|
56
|
+
ctx.home.widget({
|
|
57
|
+
id: 'hello-widget',
|
|
58
|
+
title: 'Hello',
|
|
59
|
+
render: () => '<p id="hello">hi</p>',
|
|
60
|
+
}),
|
|
61
|
+
)
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
await feature.await()
|
|
65
|
+
assert.match(
|
|
66
|
+
await page(),
|
|
67
|
+
/<section class="widget" data-widget="hello-widget">[\s\S]*<p id="hello">hi<\/p>/,
|
|
68
|
+
)
|
|
69
|
+
await feature.dispose()
|
|
70
|
+
assert.doesNotMatch(await page(), /data-widget="hello-widget"/)
|
|
71
|
+
}))
|
|
72
|
+
|
|
47
73
|
/** A sandbox may forbid listening on a port (Codex's workspace-write does); the one test that starts the real server is skipped there, and says so. */
|
|
48
74
|
/**
|
|
49
75
|
* Whether this environment forbids listening at all: only a sandbox's refusal (EPERM/EACCES) means the real-server test must skip. A loaded
|