acrylblends 1.3.1 → 1.3.2
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/legacy/README.md +4 -1
- package/legacy/agent/858de7a1ad0b/src/app.ts +289 -0
- package/legacy/agent/858de7a1ad0b/src/routes.ts +145 -0
- package/legacy/agent/b96ba6e3598a/src/app.ts +285 -0
- package/legacy/agent/b96ba6e3598a/src/routes.ts +145 -0
- package/legacy/agent/ecbc84f1c1e5/src/app.ts +291 -0
- package/legacy/agent/ecbc84f1c1e5/src/routes.ts +145 -0
- package/legacy/blank/2447ac6d5d24/src/app.ts +61 -0
- package/legacy/blank/387f7e25aaeb/src/app.ts +81 -0
- package/legacy/blank/5b9f10e1ba5e/src/app.ts +61 -0
- package/legacy/blank/5f6dd8910c50/src/app.ts +152 -0
- package/legacy/blank/5f6dd8910c50/src/routes.ts +75 -0
- package/legacy/blank/a7e8f539ffee/src/app.ts +160 -0
- package/legacy/blank/a7e8f539ffee/src/routes.ts +75 -0
- package/legacy/blank/bdb9735e013b/src/app.ts +61 -0
- package/legacy/blank/c3be1d735b1a/src/app.ts +61 -0
- package/legacy/blank/c3be1d735b1a/src/routes.ts +18 -0
- package/legacy/blank/e04303a7f8dd/src/app.ts +61 -0
- package/legacy/chat/5120c4a9fa61/src/app.ts +157 -0
- package/legacy/chat/5120c4a9fa61/src/routes.ts +29 -0
- package/legacy/chat/d4464dfb3b04/src/app.ts +165 -0
- package/legacy/chat/d4464dfb3b04/src/routes.ts +29 -0
- package/legacy/frozen.json +7 -0
- package/legacy/layouts.json +135 -45
- package/legacy/nodejs/9d59115118c7/src/app.ts +150 -0
- package/legacy/nodejs/9d59115118c7/src/routes.ts +59 -0
- package/legacy/nodejs/a6a340b4d3a6/src/app.ts +158 -0
- package/legacy/nodejs/a6a340b4d3a6/src/routes.ts +59 -0
- package/legacy/notes/fb43a1c5f3b4/src/app.ts +165 -0
- package/legacy/notes/fb43a1c5f3b4/src/routes.ts +21 -0
- package/legacy/notes/fc3fe7c749d2/src/app.ts +157 -0
- package/legacy/notes/fc3fe7c749d2/src/routes.ts +21 -0
- package/legacy/todo/164274ffcf7f/src/app.ts +145 -0
- package/legacy/todo/164274ffcf7f/src/todo.ts +137 -0
- package/package.json +1 -1
- /package/legacy/blank/{d6d371c9a65e → 2447ac6d5d24}/src/routes.ts +0 -0
- /package/legacy/blank/{5a91c558af22 → 387f7e25aaeb}/src/routes.ts +0 -0
- /package/legacy/blank/{b2cbb759c889 → 5b9f10e1ba5e}/src/routes.ts +0 -0
- /package/legacy/blank/{9c650cf52bf3 → bdb9735e013b}/src/routes.ts +0 -0
- /package/legacy/blank/{aea589283445 → e04303a7f8dd}/src/routes.ts +0 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
|
5
|
+
import * as door from '../plugins/acryl-door/src/index.ts'
|
|
6
|
+
import * as nodeServer from '../plugins/acryl-node-server/src/index.ts'
|
|
7
|
+
import * as router from '../plugins/acryl-router/src/index.ts'
|
|
8
|
+
import * as state from '../plugins/acryl-state/src/index.ts'
|
|
9
|
+
import { agent, memory, model, scripted, tools } from '../plugins/agent/index.ts'
|
|
10
|
+
import * as panel from '../plugins/agent-panel/index.ts'
|
|
11
|
+
import { builder } from '../plugins/builder/index.ts'
|
|
12
|
+
import { extensions } from '../plugins/extensions/index.ts'
|
|
13
|
+
import { type AppInstance, instance } from '../plugins/instance/index.ts'
|
|
14
|
+
import {
|
|
15
|
+
appPluginNames,
|
|
16
|
+
blueprintsIn,
|
|
17
|
+
compose,
|
|
18
|
+
loadAppPlugins,
|
|
19
|
+
loadManifest,
|
|
20
|
+
type ParameterValue,
|
|
21
|
+
} from '../plugins/manifest/index.ts'
|
|
22
|
+
import { ASSISTANT_PROMPT, type OpenAIConfig, openai } from '../plugins/openai/index.ts'
|
|
23
|
+
import { pluginShare } from '../plugins/plugin-share/index.ts'
|
|
24
|
+
import { sandboxPlugin } from '../plugins/sandbox/index.ts'
|
|
25
|
+
import { save } from '../plugins/save/index.ts'
|
|
26
|
+
import { tasks } from '../plugins/tasks/index.ts'
|
|
27
|
+
import { FRAME_MOUNT_PREFIX, routes } from './routes.ts'
|
|
28
|
+
|
|
29
|
+
/** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
|
|
30
|
+
export const registry = {
|
|
31
|
+
tasks,
|
|
32
|
+
model,
|
|
33
|
+
tools,
|
|
34
|
+
memory,
|
|
35
|
+
agent,
|
|
36
|
+
'acryl-router': router,
|
|
37
|
+
'acryl-state': state,
|
|
38
|
+
extensions,
|
|
39
|
+
builder,
|
|
40
|
+
'plugin-share': pluginShare,
|
|
41
|
+
save,
|
|
42
|
+
sandbox: sandboxPlugin,
|
|
43
|
+
routes,
|
|
44
|
+
'acryl-node-server': nodeServer,
|
|
45
|
+
'agent-panel': panel,
|
|
46
|
+
'acryl-door': door,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The manifest next to this app's source. */
|
|
50
|
+
export const MANIFEST = join(fileURLToPath(new URL('..', import.meta.url)), 'blend.yaml')
|
|
51
|
+
/** The app's own plugins (a feature each), which `blend.yaml` mounts by their folder name next to the library copies `registry` imports. */
|
|
52
|
+
const PLUGINS_DIR = join(fileURLToPath(new URL('..', import.meta.url)), 'plugins')
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The app's manifest, resolved. A Blend finds its Blueprint in `blueprints/<id>.yaml`; an app that is itself a Blueprint never looks.
|
|
56
|
+
* `createApp` and the tests read it the same way.
|
|
57
|
+
*/
|
|
58
|
+
export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
|
|
59
|
+
return loadManifest(MANIFEST, {
|
|
60
|
+
known: [...Object.keys(registry), ...appPluginNames(PLUGINS_DIR)],
|
|
61
|
+
getBlueprint: blueprintsIn(join(fileURLToPath(new URL('..', import.meta.url)), 'blueprints')),
|
|
62
|
+
...(values ? { values } : {}),
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface App {
|
|
67
|
+
readonly root: Context
|
|
68
|
+
/** The address the server listens on; empty when the app was made with `listen: false`. */
|
|
69
|
+
readonly url: string
|
|
70
|
+
stop(): Promise<void>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The development rows, and what each one gives a visitor. Production leaves the agent and the builder out. The extensions row is not left out: it starts again, read-only,
|
|
75
|
+
* the extensions in the app's `plugins/` folder (what the agent built and installed in development, committed with the app), so a deployed app has its own features; nothing
|
|
76
|
+
* can be installed, updated or approved while it runs, and `/ask`, `/extensions*` and `/tasks*` stay unmounted (they need the agent). `ACRYL_PRODUCTION_KEEP=extensions` keeps the
|
|
77
|
+
* live install itself.
|
|
78
|
+
*/
|
|
79
|
+
const DEVELOPMENT_ROWS: Record<string, string> = {
|
|
80
|
+
agent: 'POST /ask runs the agent, which spends the model key and calls its tools',
|
|
81
|
+
builder: 'the agent can write plugin source into plugins/',
|
|
82
|
+
'plugin-share': 'a plugin pulled from a release runs in this process',
|
|
83
|
+
extensions:
|
|
84
|
+
'a plugin installed live runs in this process, and POST /extensions/approve decides installs',
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The only tools the production Assistant's agent may call: look at the page and act on it. */
|
|
88
|
+
const ASSISTANT_TOOLS = ['door_look', 'door_act']
|
|
89
|
+
|
|
90
|
+
/** The most tokens one answer of the Assistant's model may use: with the step budget, what one visitor's question can cost at most. */
|
|
91
|
+
const ASSISTANT_MAX_TOKENS = 1024
|
|
92
|
+
|
|
93
|
+
/** Model calls per visitor question: look, open a page, look, filter, look and answer is six, with room for one wrong turn. */
|
|
94
|
+
const ASSISTANT_STEPS = 10
|
|
95
|
+
|
|
96
|
+
/** What the Assistant says while the app has no model: the scripted one cannot use the page. */
|
|
97
|
+
export const ASSISTANT_NO_MODEL =
|
|
98
|
+
"This assistant is not set up yet: the app's owner has not chosen a model for it."
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The app's rows in the manifest's order, composed into `ctx`: the app's own server calls this from `createApp`, and the generated frame layer mounts it
|
|
102
|
+
* as one row, so the frame runs the app exactly as the app's own server does (same rows, same order, same config). `host: true` leaves the server and the
|
|
103
|
+
* door to the frame host, which mounts its own.
|
|
104
|
+
*/
|
|
105
|
+
export async function composeInto(
|
|
106
|
+
ctx: Context,
|
|
107
|
+
chosen: AppInstance,
|
|
108
|
+
options: {
|
|
109
|
+
values?: Readonly<Record<string, ParameterValue>>
|
|
110
|
+
model?: OpenAIConfig
|
|
111
|
+
listen?: boolean
|
|
112
|
+
host?: boolean
|
|
113
|
+
} = {},
|
|
114
|
+
): Promise<Fiber[]> {
|
|
115
|
+
const read = readManifest(options.values)
|
|
116
|
+
// Rows left out: the server (and so the door, which needs it) when the app is reached in process; the door in production and with ACRYL_DOOR=off (one process at a time
|
|
117
|
+
// serves a folder's door, so the tests that start several apps set it).
|
|
118
|
+
const listen = options.listen !== false && options.host !== true
|
|
119
|
+
const leaveOut = new Set<string>()
|
|
120
|
+
let extensionsReadOnly = false
|
|
121
|
+
// In the frame host the infrastructure rows are the host's own (its server, its web-server port, its router, the app instance, the door): the app
|
|
122
|
+
// contributes only the plugins that are its own.
|
|
123
|
+
if (!listen || options.host === true) leaveOut.add('acryl-node-server').add('acryl-door')
|
|
124
|
+
if (options.host === true)
|
|
125
|
+
for (const row of [
|
|
126
|
+
'instance',
|
|
127
|
+
'acryl-router',
|
|
128
|
+
'acryl-web-server',
|
|
129
|
+
'acryl-page',
|
|
130
|
+
'manifest',
|
|
131
|
+
'save',
|
|
132
|
+
])
|
|
133
|
+
leaveOut.add(row)
|
|
134
|
+
// The Assistant (production, ACRYL_ASSISTANT=on): the agent panel docked for the app's visitors. Its agent keeps only the page tools and the door only the page,
|
|
135
|
+
// so it uses the app for a visitor (opens pages, filters, fills in forms) and cannot change the app, its code or its secrets. Not in the frame host.
|
|
136
|
+
const assistant =
|
|
137
|
+
process.env.NODE_ENV === 'production' &&
|
|
138
|
+
process.env.ACRYL_ASSISTANT === 'on' &&
|
|
139
|
+
process.env.ACRYL_DOOR !== 'off' &&
|
|
140
|
+
listen
|
|
141
|
+
if (!assistant && (process.env.NODE_ENV === 'production' || process.env.ACRYL_DOOR === 'off'))
|
|
142
|
+
leaveOut.add('acryl-door')
|
|
143
|
+
// Production: the rows that give a visitor power over the running app or the owner's model key are left out too. The agent (`POST /ask` spends the model key and runs tools),
|
|
144
|
+
// the builder and the extensions (live plugin install runs code in this process, and `POST /extensions/approve` decides it) are development tools. `ACRYL_PRODUCTION_KEEP`
|
|
145
|
+
// names the ones to keep (`agent,builder,extensions`); each one kept is announced at start, because none of them is behind a login.
|
|
146
|
+
if (process.env.NODE_ENV === 'production') {
|
|
147
|
+
const keep = new Set(
|
|
148
|
+
(process.env.ACRYL_PRODUCTION_KEEP ?? '')
|
|
149
|
+
.split(',')
|
|
150
|
+
.map((name) => name.trim())
|
|
151
|
+
.filter((name) => name !== ''),
|
|
152
|
+
)
|
|
153
|
+
for (const name of keep)
|
|
154
|
+
if (!(name in DEVELOPMENT_ROWS))
|
|
155
|
+
console.warn(
|
|
156
|
+
`WARNING: ACRYL_PRODUCTION_KEEP names "${name}", which is not one of ${Object.keys(DEVELOPMENT_ROWS).join(', ')}`,
|
|
157
|
+
)
|
|
158
|
+
for (const [row, danger] of Object.entries(DEVELOPMENT_ROWS)) {
|
|
159
|
+
if (keep.has(row))
|
|
160
|
+
console.warn(
|
|
161
|
+
`WARNING: the "${row}" row runs in production because ACRYL_PRODUCTION_KEEP names it: ${danger}. Nothing here is behind a login; put the app behind your own.`,
|
|
162
|
+
)
|
|
163
|
+
else if (row === 'extensions') extensionsReadOnly = true
|
|
164
|
+
else if (!(assistant && row === 'agent')) leaveOut.add(row)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const manifest = {
|
|
168
|
+
...read,
|
|
169
|
+
plugins: read.plugins.map((row) => (leaveOut.has(row.use) ? { ...row, disabled: true } : row)),
|
|
170
|
+
}
|
|
171
|
+
const extensionsDir = join(chosen.root, 'plugins')
|
|
172
|
+
// One release of grace: an app made before 0.7.8 kept what the agent wrote in `extensions/`. It is still installed, with a warning.
|
|
173
|
+
const legacyDir = join(chosen.root, 'extensions')
|
|
174
|
+
const legacy = existsSync(legacyDir)
|
|
175
|
+
if (legacy)
|
|
176
|
+
console.warn(
|
|
177
|
+
'warning: extensions/ is the old folder for plugins the agent writes; move them to plugins/ ("git mv extensions/<name> plugins/<name>"). extensions/ is read for this release only.',
|
|
178
|
+
)
|
|
179
|
+
// The Assistant's model is told it helps a visitor through the page alone, and each answer is capped, unless the app's model configuration says otherwise.
|
|
180
|
+
const modelConfig =
|
|
181
|
+
options.model && assistant
|
|
182
|
+
? {
|
|
183
|
+
...options.model,
|
|
184
|
+
system: options.model.system ?? ASSISTANT_PROMPT,
|
|
185
|
+
maxTokens: options.model.maxTokens ?? ASSISTANT_MAX_TOKENS,
|
|
186
|
+
}
|
|
187
|
+
: options.model
|
|
188
|
+
if (assistant && !options.model)
|
|
189
|
+
console.warn(
|
|
190
|
+
`WARNING: the Assistant has no model, so it answers every visitor "${ASSISTANT_NO_MODEL}" Set the app's model (ACRYLBLENDS_MODEL_URL and ACRYLBLENDS_MODEL, or model.json in the app's home).`,
|
|
191
|
+
)
|
|
192
|
+
// The rows that name one of the app's own plugin folders (a feature: plugins/<name>/ and its row) rather than a registry import. Each one that runs
|
|
193
|
+
// is imported here; all of them, off or on, belong to their row, so the extensions row never starts them a second time.
|
|
194
|
+
const ownRows = [
|
|
195
|
+
...new Set(read.plugins.map((row) => row.use).filter((use) => !Object.hasOwn(registry, use))),
|
|
196
|
+
]
|
|
197
|
+
const ownPlugins = await loadAppPlugins(
|
|
198
|
+
PLUGINS_DIR,
|
|
199
|
+
ownRows.filter((use) => manifest.plugins.some((row) => row.use === use && !row.disabled)),
|
|
200
|
+
)
|
|
201
|
+
const fibers: Fiber[] = [
|
|
202
|
+
// The `model` row mounts the scripted model, or the real one when a model is configured.
|
|
203
|
+
...(await compose(
|
|
204
|
+
ctx,
|
|
205
|
+
manifest,
|
|
206
|
+
{ ...registry, ...ownPlugins, ...(modelConfig ? { model: openai } : {}) },
|
|
207
|
+
{
|
|
208
|
+
...(modelConfig ? { model: { ...modelConfig } } : {}),
|
|
209
|
+
// Runtime values the manifest cannot hold: the manifest's own digest (every task records it), and who may install.
|
|
210
|
+
tasks: { manifest: `sha256:${manifest.digest}` },
|
|
211
|
+
// The `approval` parameter in blend.yaml (the extensions row) turns this source into one that asks.
|
|
212
|
+
extensions: {
|
|
213
|
+
...(extensionsReadOnly ? { readOnly: true } : {}),
|
|
214
|
+
sources: [
|
|
215
|
+
{ dir: extensionsDir, trust: 'install' },
|
|
216
|
+
...(legacy ? [{ dir: legacyDir, trust: 'install' as const }] : []),
|
|
217
|
+
],
|
|
218
|
+
rows: ownRows,
|
|
219
|
+
// Messages name a route as the plugin wrote it, not under the frame's canvas prefix.
|
|
220
|
+
...(options.host === true ? { routePrefix: FRAME_MOUNT_PREFIX } : {}),
|
|
221
|
+
},
|
|
222
|
+
builder: { dir: extensionsDir },
|
|
223
|
+
'plugin-share': { dir: extensionsDir },
|
|
224
|
+
...(assistant
|
|
225
|
+
? {
|
|
226
|
+
// Every answer comes right after a look, so it says what the visitor's page shows now.
|
|
227
|
+
agent: { tools: ASSISTANT_TOOLS, steps: ASSISTANT_STEPS, answerAfter: 'door_look' },
|
|
228
|
+
'acryl-door': { pageOnly: true },
|
|
229
|
+
'agent-panel': { assistant: true },
|
|
230
|
+
// The visitor reaches the agent through the panel only: no /ask, /tasks or /extensions routes.
|
|
231
|
+
routes: { agentRoutes: false },
|
|
232
|
+
}
|
|
233
|
+
: {}),
|
|
234
|
+
// The rows that serve routes of their own mount them under the app's prefix, as makeApp does. On the app's own server that prefix is the root (the
|
|
235
|
+
// row's default); the frame host serves the app at FRAME_MOUNT_PREFIX, and its own `/api` routes would answer for a row left at the root. The
|
|
236
|
+
// app's own routes row takes the same prefix for its mount and for the absolute URLs its pages write, so the page and its assets answer at `/app`.
|
|
237
|
+
...(options.host === true
|
|
238
|
+
? {
|
|
239
|
+
'agent-panel': { mount: FRAME_MOUNT_PREFIX },
|
|
240
|
+
'driver-pty': { mount: FRAME_MOUNT_PREFIX },
|
|
241
|
+
feedback: { mount: FRAME_MOUNT_PREFIX },
|
|
242
|
+
routes: { prefix: FRAME_MOUNT_PREFIX, ...(assistant ? { agentRoutes: false } : {}) },
|
|
243
|
+
}
|
|
244
|
+
: {}),
|
|
245
|
+
// The frame host runs the server itself and its app instance carries no port; only the app's own server configures one.
|
|
246
|
+
...(options.host === true
|
|
247
|
+
? {}
|
|
248
|
+
: {
|
|
249
|
+
server: {
|
|
250
|
+
port: chosen.port.start,
|
|
251
|
+
scan: chosen.port.scan ? 20 : 0,
|
|
252
|
+
hostname: chosen.host,
|
|
253
|
+
},
|
|
254
|
+
}),
|
|
255
|
+
},
|
|
256
|
+
)),
|
|
257
|
+
]
|
|
258
|
+
// Without a model the scripted one would echo the visitor's words back: it says in one sentence that the Assistant is not set up.
|
|
259
|
+
if (assistant && !options.model)
|
|
260
|
+
scripted(ctx.get('model') as Parameters<typeof scripted>[0])?.teach(() => ({
|
|
261
|
+
text: ASSISTANT_NO_MODEL,
|
|
262
|
+
}))
|
|
263
|
+
return fibers
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. The app's `plugins/`
|
|
268
|
+
* folder is trusted to install at start, and is where the builder writes.
|
|
269
|
+
*/
|
|
270
|
+
export async function createApp(
|
|
271
|
+
chosen: AppInstance,
|
|
272
|
+
options: {
|
|
273
|
+
values?: Readonly<Record<string, ParameterValue>>
|
|
274
|
+
/** A real model (any OpenAI-compatible API) in place of the offline scripted one. */
|
|
275
|
+
model?: OpenAIConfig
|
|
276
|
+
/** Start the HTTP server (default). Tests pass `false` and call `app.root.router.request(path)`: it needs no port, so it also runs where a sandbox forbids listening. */
|
|
277
|
+
listen?: boolean
|
|
278
|
+
} = {},
|
|
279
|
+
): Promise<App> {
|
|
280
|
+
const root = new Context()
|
|
281
|
+
const first = root.plugin(instance, chosen)
|
|
282
|
+
await first.await()
|
|
283
|
+
const fibers: Fiber[] = [first, ...(await composeInto(root, chosen, options))]
|
|
284
|
+
return {
|
|
285
|
+
root,
|
|
286
|
+
url: options.listen === false ? '' : root.nodeServer.url,
|
|
287
|
+
async stop() {
|
|
288
|
+
for (const fiber of [...fibers].reverse()) await fiber.dispose()
|
|
289
|
+
},
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import { Acryl } from '../plugins/acryl-router/src/index.ts'
|
|
3
|
+
import { bodyLimit } from '../plugins/acryl-router/src/middleware/index.ts'
|
|
4
|
+
import type {} from '../plugins/agent/index.ts'
|
|
5
|
+
import type {} from '../plugins/extensions/index.ts'
|
|
6
|
+
import type {} from '../plugins/tasks/index.ts'
|
|
7
|
+
|
|
8
|
+
export const APP_NAME = '__APP_NAME__'
|
|
9
|
+
|
|
10
|
+
/** The only place the app's mount prefix is named: the root on its own server (`npm start`), `/app` when the ACRYL frame hosts it (`npm run dev`). */
|
|
11
|
+
export const FRAME_MOUNT_PREFIX = '/app'
|
|
12
|
+
|
|
13
|
+
/** The door's page script (`.acryl/bin/acryl look` and `act`), in development only: `src/app.ts` leaves the door out in production and with ACRYL_DOOR=off. */
|
|
14
|
+
const DOOR_TAG =
|
|
15
|
+
process.env.NODE_ENV !== 'production' && process.env.ACRYL_DOOR !== 'off'
|
|
16
|
+
? '<script type="module" src="/api/acryl-door/page.js"></script>'
|
|
17
|
+
: ''
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The app's own routes on the router. Always: the page and `GET /health`. While the agent, the extensions and the task recorder run: `POST /ask` for the agent; `GET /extensions`
|
|
21
|
+
* (and `POST /extensions/approve` and `/deny`) and `GET /tasks` to observe and steer what it did (every run is a durable task owning one task per tool call); `POST /tasks/cancel`.
|
|
22
|
+
* What changes is also streamed: `GET /api/state/tasks/events` and `GET /api/state/extensions/events` (a page follows them with `watch` of `/api/state/client.js`, over one WebSocket). In production `src/app.ts` leaves the agent and builder rows out
|
|
23
|
+
* and starts the extensions read-only (unless `ACRYL_PRODUCTION_KEEP` names them), and then those routes are not mounted at all. The mounts are removed when the plugin is disposed.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* The app's look: the person's choice from /settings (kept in this browser), as `GET /appearance.js`. One script tag in a page's head (`<script src="/appearance.js"></script>`) applies it
|
|
27
|
+
* before the page paints, again whenever another window of the app changes it (the `storage` event), and defines the colours as custom properties for the page to use:
|
|
28
|
+
* `--bg`, `--fg`, `--muted`, `--surface`, `--border`, `--accent` (`html[data-theme=dark|light]` is set when the person chose one, and the system's choice decides otherwise). A page the agent builds
|
|
29
|
+
* carries the tag and colours itself with `var(--bg)` and the rest, and so follows Dark and Light.
|
|
30
|
+
*/
|
|
31
|
+
const APPEARANCE_JS = `(function () {
|
|
32
|
+
var root = document.documentElement
|
|
33
|
+
function apply() {
|
|
34
|
+
var saved
|
|
35
|
+
try { saved = localStorage.getItem('theme') } catch (e) { /* storage blocked: follow the system */ }
|
|
36
|
+
if (saved === 'dark' || saved === 'light') root.dataset.theme = saved
|
|
37
|
+
else delete root.dataset.theme
|
|
38
|
+
}
|
|
39
|
+
apply()
|
|
40
|
+
addEventListener('storage', function (event) { if (event.key === null || event.key === 'theme') apply() })
|
|
41
|
+
var light = '--bg:#ffffff;--fg:#111827;--muted:#6b7280;--surface:#f3f4f6;--border:#d1d5db;--accent:#2563eb'
|
|
42
|
+
var dark = '--bg:#111827;--fg:#f9fafb;--muted:#9ca3af;--surface:#1f2937;--border:#374151;--accent:#60a5fa'
|
|
43
|
+
var style = document.createElement('style')
|
|
44
|
+
style.id = 'appearance'
|
|
45
|
+
style.textContent = ':root{color-scheme:light dark;' + light + '}'
|
|
46
|
+
+ '@media (prefers-color-scheme:dark){html:not([data-theme]){' + dark + '}}'
|
|
47
|
+
+ 'html[data-theme=dark]{color-scheme:dark;' + dark + '}'
|
|
48
|
+
+ 'html[data-theme=light]{color-scheme:light;' + light + '}'
|
|
49
|
+
+ 'html{background:var(--bg);color:var(--fg)}'
|
|
50
|
+
;(document.head || root).appendChild(style)
|
|
51
|
+
})()
|
|
52
|
+
`
|
|
53
|
+
/** The home and settings pages' own layout; their colours come from /appearance.js. */
|
|
54
|
+
const APPEARANCE_STYLE =
|
|
55
|
+
'<style>body{font:16px system-ui,sans-serif;margin:2rem auto;max-width:42rem;padding:0 1rem}a{color:inherit}</style>'
|
|
56
|
+
const appearanceScript = (prefix: string) => `<script src="${prefix}/appearance.js"></script>`
|
|
57
|
+
|
|
58
|
+
/** `/settings`: the one setting the template has, the appearance. The desktop app's Settings menu item opens this page. */
|
|
59
|
+
const settingsPage = (prefix: string) =>
|
|
60
|
+
`<!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
|
+
* 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.
|
|
65
|
+
*/
|
|
66
|
+
export function makeApp(prefix = ''): Acryl {
|
|
67
|
+
const limit = bodyLimit({ maxSize: 64 * 1024 })
|
|
68
|
+
return (
|
|
69
|
+
new Acryl()
|
|
70
|
+
.use(limit)
|
|
71
|
+
// 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) =>
|
|
73
|
+
(c.req.header('accept') ?? '').includes('text/html')
|
|
74
|
+
? 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>`,
|
|
76
|
+
)
|
|
77
|
+
: c.json({ app: APP_NAME, ok: true }),
|
|
78
|
+
)
|
|
79
|
+
.get('/health', (c) => c.json({ ok: true }))
|
|
80
|
+
.get('/settings', (c) => c.html(settingsPage(prefix)))
|
|
81
|
+
.get('/appearance.js', (c) =>
|
|
82
|
+
c.body(APPEARANCE_JS, 200, {
|
|
83
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
84
|
+
'cache-control': 'no-cache',
|
|
85
|
+
}),
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The app's own routes: an `App` mounted on the router at `options.prefix` (empty or absent: the root). */
|
|
91
|
+
export const routes = {
|
|
92
|
+
name: 'routes',
|
|
93
|
+
inject: ['router'],
|
|
94
|
+
apply(ctx: Context, options: { readonly prefix?: string; readonly agentRoutes?: boolean } = {}) {
|
|
95
|
+
const prefix = options.prefix ?? ''
|
|
96
|
+
// A body here is a name, an id or a question; anything bigger is refused before it is read.
|
|
97
|
+
const json = async (c: {
|
|
98
|
+
req: { json(): Promise<unknown> }
|
|
99
|
+
}): Promise<Record<string, unknown>> => {
|
|
100
|
+
const body = await c.req.json().catch(() => undefined)
|
|
101
|
+
return typeof body === 'object' && body !== null ? (body as Record<string, unknown>) : {}
|
|
102
|
+
}
|
|
103
|
+
const limit = bodyLimit({ maxSize: 64 * 1024 })
|
|
104
|
+
// The app's own page and routes at the one prefix; the agent routes below mount at the same prefix.
|
|
105
|
+
ctx.router.route(prefix === '' ? '/' : prefix, makeApp(prefix))
|
|
106
|
+
// The routes that give a visitor the agent: mounted only while the agent, the extensions and the tasks run (see the header).
|
|
107
|
+
// `agentRoutes: false` (the production Assistant) keeps them unmounted although the agent runs.
|
|
108
|
+
if (options.agentRoutes === false) return
|
|
109
|
+
ctx.inject(['agent', 'extensions', 'tasks'], (inner) => {
|
|
110
|
+
const app = new Acryl().use(limit).get('/extensions', (c) =>
|
|
111
|
+
c.json({
|
|
112
|
+
installed: inner.extensions.list(),
|
|
113
|
+
notInstalled: inner.extensions.pending(),
|
|
114
|
+
waitingForApproval: inner.extensions.approvals(),
|
|
115
|
+
}),
|
|
116
|
+
)
|
|
117
|
+
// The owner's decision on an install that is waiting (the `approval` parameter in blend.yaml).
|
|
118
|
+
for (const [path, decide] of [
|
|
119
|
+
['/extensions/approve', (name: string) => inner.extensions.approve(name)],
|
|
120
|
+
['/extensions/deny', (name: string) => inner.extensions.deny(name)],
|
|
121
|
+
] as const) {
|
|
122
|
+
app.post(path, async (c) => {
|
|
123
|
+
const { name } = await json(c)
|
|
124
|
+
if (typeof name !== 'string') return c.json({ error: 'name must be a string' }, 400)
|
|
125
|
+
return c.json(await decide(name))
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
app
|
|
129
|
+
.get('/tasks', (c) => c.json(inner.tasks.list().slice(0, 100)))
|
|
130
|
+
.post('/tasks/cancel', async (c) => {
|
|
131
|
+
const { id } = await json(c)
|
|
132
|
+
if (typeof id !== 'string' || !inner.tasks.get(id))
|
|
133
|
+
return c.json({ error: 'no such task' }, 404)
|
|
134
|
+
return c.json({ cancelled: inner.tasks.cancel(id) })
|
|
135
|
+
})
|
|
136
|
+
.post('/ask', async (c) => {
|
|
137
|
+
const { question } = await json(c)
|
|
138
|
+
if (typeof question !== 'string')
|
|
139
|
+
return c.json({ error: 'question must be a string' }, 400)
|
|
140
|
+
return c.json(await inner.agent.run(question))
|
|
141
|
+
})
|
|
142
|
+
inner.router.route(prefix === '' ? '/' : prefix, app)
|
|
143
|
+
})
|
|
144
|
+
},
|
|
145
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
|
4
|
+
import * as nodeServer from './plugins/acryl-node-server/src/index.ts'
|
|
5
|
+
import * as router from './plugins/acryl-router/src/index.ts'
|
|
6
|
+
import { type AppInstance, instance } from './plugins/instance/index.ts'
|
|
7
|
+
import {
|
|
8
|
+
blueprintsIn,
|
|
9
|
+
compose,
|
|
10
|
+
loadManifest,
|
|
11
|
+
type ParameterValue,
|
|
12
|
+
} from './plugins/manifest/index.ts'
|
|
13
|
+
import { routes } from './routes.ts'
|
|
14
|
+
|
|
15
|
+
/** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
|
|
16
|
+
export const registry = { 'acryl-router': router, 'acryl-node-server': nodeServer, routes }
|
|
17
|
+
|
|
18
|
+
/** The manifest next to this app's source. */
|
|
19
|
+
export const MANIFEST = join(fileURLToPath(new URL('..', import.meta.url)), 'blend.yaml')
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The app's manifest, resolved. A Blend finds its Blueprint in `blueprints/<id>.yaml`; an app that is itself a Blueprint never looks.
|
|
23
|
+
* `createApp` and the tests read it the same way.
|
|
24
|
+
*/
|
|
25
|
+
export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
|
|
26
|
+
return loadManifest(MANIFEST, {
|
|
27
|
+
known: Object.keys(registry),
|
|
28
|
+
getBlueprint: blueprintsIn(join(fileURLToPath(new URL('..', import.meta.url)), 'blueprints')),
|
|
29
|
+
...(values ? { values } : {}),
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface App {
|
|
34
|
+
readonly root: Context
|
|
35
|
+
readonly url: string
|
|
36
|
+
stop(): Promise<void>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
|
|
40
|
+
export async function createApp(
|
|
41
|
+
chosen: AppInstance,
|
|
42
|
+
options: { values?: Readonly<Record<string, ParameterValue>> } = {},
|
|
43
|
+
): Promise<App> {
|
|
44
|
+
const manifest = readManifest(options.values)
|
|
45
|
+
const root = new Context()
|
|
46
|
+
const first = root.plugin(instance, chosen)
|
|
47
|
+
await first.await()
|
|
48
|
+
const fibers: Fiber[] = [
|
|
49
|
+
first,
|
|
50
|
+
...(await compose(root, manifest, registry, {
|
|
51
|
+
server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
|
|
52
|
+
})),
|
|
53
|
+
]
|
|
54
|
+
return {
|
|
55
|
+
root,
|
|
56
|
+
url: root.nodeServer.url,
|
|
57
|
+
async stop() {
|
|
58
|
+
for (const fiber of [...fibers].reverse()) await fiber.dispose()
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
|
4
|
+
import * as nodeServer from '../plugins/acryl-node-server/src/index.ts'
|
|
5
|
+
import * as router from '../plugins/acryl-router/src/index.ts'
|
|
6
|
+
import { type AppInstance, instance } from '../plugins/instance/index.ts'
|
|
7
|
+
import {
|
|
8
|
+
blueprintsIn,
|
|
9
|
+
compose,
|
|
10
|
+
loadManifest,
|
|
11
|
+
type ParameterValue,
|
|
12
|
+
} from '../plugins/manifest/index.ts'
|
|
13
|
+
import { routes } from './routes.ts'
|
|
14
|
+
|
|
15
|
+
/** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
|
|
16
|
+
export const registry = { 'acryl-router': router, 'acryl-node-server': nodeServer, routes }
|
|
17
|
+
|
|
18
|
+
/** The manifest next to this app's source. */
|
|
19
|
+
export const MANIFEST = join(fileURLToPath(new URL('..', import.meta.url)), 'blend.yaml')
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The app's manifest, resolved. A Blend finds its Blueprint in `blueprints/<id>.yaml`; an app that is itself a Blueprint never looks.
|
|
23
|
+
* `createApp` and the tests read it the same way.
|
|
24
|
+
*/
|
|
25
|
+
export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
|
|
26
|
+
return loadManifest(MANIFEST, {
|
|
27
|
+
known: Object.keys(registry),
|
|
28
|
+
getBlueprint: blueprintsIn(join(fileURLToPath(new URL('..', import.meta.url)), 'blueprints')),
|
|
29
|
+
...(values ? { values } : {}),
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The manifest without its server row: the app is then reached in process, through `root.router.request`. */
|
|
34
|
+
function withoutServer<M extends { plugins: readonly { use: string }[] }>(manifest: M): M {
|
|
35
|
+
return {
|
|
36
|
+
...manifest,
|
|
37
|
+
plugins: manifest.plugins.map((row) =>
|
|
38
|
+
row.use === 'acryl-node-server' ? { ...row, disabled: true } : row,
|
|
39
|
+
),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface App {
|
|
44
|
+
readonly root: Context
|
|
45
|
+
/** The address the server listens on; empty when the app was made with `listen: false`. */
|
|
46
|
+
readonly url: string
|
|
47
|
+
stop(): Promise<void>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
|
|
51
|
+
export async function createApp(
|
|
52
|
+
chosen: AppInstance,
|
|
53
|
+
options: {
|
|
54
|
+
values?: Readonly<Record<string, ParameterValue>>
|
|
55
|
+
/** Start the HTTP server (default). Tests pass `false` and call `app.root.router.request(path)`: it needs no port, so it also runs where a sandbox forbids listening. */
|
|
56
|
+
listen?: boolean
|
|
57
|
+
} = {},
|
|
58
|
+
): Promise<App> {
|
|
59
|
+
const manifest = readManifest(options.values)
|
|
60
|
+
const root = new Context()
|
|
61
|
+
const first = root.plugin(instance, chosen)
|
|
62
|
+
await first.await()
|
|
63
|
+
const fibers: Fiber[] = [
|
|
64
|
+
first,
|
|
65
|
+
...(await compose(
|
|
66
|
+
root,
|
|
67
|
+
options.listen === false ? withoutServer(manifest) : manifest,
|
|
68
|
+
registry,
|
|
69
|
+
{
|
|
70
|
+
server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
|
|
71
|
+
},
|
|
72
|
+
)),
|
|
73
|
+
]
|
|
74
|
+
return {
|
|
75
|
+
root,
|
|
76
|
+
url: options.listen === false ? '' : root.nodeServer.url,
|
|
77
|
+
async stop() {
|
|
78
|
+
for (const fiber of [...fibers].reverse()) await fiber.dispose()
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
|
4
|
+
import * as nodeServer from './plugins/acryl-node-server/src/index.ts'
|
|
5
|
+
import * as router from './plugins/acryl-router/src/index.ts'
|
|
6
|
+
import { type AppInstance, instance } from './plugins/instance/index.ts'
|
|
7
|
+
import {
|
|
8
|
+
blueprintsIn,
|
|
9
|
+
compose,
|
|
10
|
+
loadManifest,
|
|
11
|
+
type ParameterValue,
|
|
12
|
+
} from './plugins/manifest/index.ts'
|
|
13
|
+
import { routes } from './routes.ts'
|
|
14
|
+
|
|
15
|
+
/** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
|
|
16
|
+
export const registry = { 'acryl-router': router, 'acryl-node-server': nodeServer, routes }
|
|
17
|
+
|
|
18
|
+
/** The manifest next to this app's source. */
|
|
19
|
+
export const MANIFEST = join(fileURLToPath(new URL('..', import.meta.url)), 'blend.yaml')
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The app's manifest, resolved. A Blend finds its Blueprint in `blueprints/<id>.yaml`; an app that is itself a Blueprint never looks.
|
|
23
|
+
* `createApp` and the tests read it the same way.
|
|
24
|
+
*/
|
|
25
|
+
export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
|
|
26
|
+
return loadManifest(MANIFEST, {
|
|
27
|
+
known: Object.keys(registry),
|
|
28
|
+
getBlueprint: blueprintsIn(join(fileURLToPath(new URL('..', import.meta.url)), 'blueprints')),
|
|
29
|
+
...(values ? { values } : {}),
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface App {
|
|
34
|
+
readonly root: Context
|
|
35
|
+
readonly url: string
|
|
36
|
+
stop(): Promise<void>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
|
|
40
|
+
export async function createApp(
|
|
41
|
+
chosen: AppInstance,
|
|
42
|
+
options: { values?: Readonly<Record<string, ParameterValue>> } = {},
|
|
43
|
+
): Promise<App> {
|
|
44
|
+
const manifest = readManifest(options.values)
|
|
45
|
+
const root = new Context()
|
|
46
|
+
const first = root.plugin(instance, chosen)
|
|
47
|
+
await first.await()
|
|
48
|
+
const fibers: Fiber[] = [
|
|
49
|
+
first,
|
|
50
|
+
...(await compose(root, manifest, registry, {
|
|
51
|
+
server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
|
|
52
|
+
})),
|
|
53
|
+
]
|
|
54
|
+
return {
|
|
55
|
+
root,
|
|
56
|
+
url: root.nodeServer.url,
|
|
57
|
+
async stop() {
|
|
58
|
+
for (const fiber of [...fibers].reverse()) await fiber.dispose()
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|