acrylblends 0.7.8 → 0.8.1
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 +13 -5
- package/dist/index.js +72 -13
- package/dist/index.js.map +1 -1
- package/docs/framework/README.md +2 -2
- package/docs/framework/UPSTREAM.md +1 -1
- package/docs/framework/getting-started/basic.md +1 -2
- package/docs/framework/getting-started/desktop.md +1 -1
- package/docs/framework/guides/bring-your-own-tool.md +216 -0
- package/docs/framework/guides/create-acrylblends.md +5 -4
- package/docs/framework/guides/data.md +2 -1
- package/docs/framework/upstream.json +3 -3
- package/lib/node-server.js +1 -0
- package/package.json +1 -1
- package/plugins/acryl-db/plugin.json +6 -1
- package/plugins/acryl-db/src/conformance.ts +224 -0
- package/plugins/acryl-db/upstream.json +1 -1
- package/plugins/acryl-db-sqlite/plugin.json +4 -1
- package/plugins/acryl-db-sqlite/upstream.json +1 -1
- package/plugins/acryl-door/LICENSE +47 -0
- package/plugins/acryl-door/plugin.json +14 -0
- package/plugins/acryl-door/src/cli/acryl.ts +207 -0
- package/plugins/acryl-door/src/host/api.ts +9 -0
- package/plugins/acryl-door/src/host/audit.ts +64 -0
- package/plugins/acryl-door/src/host/channel.ts +142 -0
- package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
- package/plugins/acryl-door/src/host/door-file.ts +94 -0
- package/plugins/acryl-door/src/host/door.ts +195 -0
- package/plugins/acryl-door/src/host/http.ts +80 -0
- package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
- package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
- package/plugins/acryl-door/src/host/page-routes.ts +65 -0
- package/plugins/acryl-door/src/host/shim.ts +65 -0
- package/plugins/acryl-door/src/host/verbs.ts +109 -0
- package/plugins/acryl-door/src/index.ts +133 -0
- package/plugins/acryl-door/src/page/api.ts +9 -0
- package/plugins/acryl-door/src/page/channel.ts +135 -0
- package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
- package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
- package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
- package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
- package/plugins/acryl-door/src/page/index.ts +40 -0
- package/plugins/acryl-door/src/page/indicator.ts +72 -0
- package/plugins/acryl-door/src/page/main.ts +7 -0
- package/plugins/acryl-door/src/page/protocol.ts +213 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
- package/plugins/acryl-door/src/page/user-input.ts +24 -0
- package/plugins/acryl-door/upstream.json +28 -0
- package/plugins/acryl-inspect/upstream.json +1 -1
- package/plugins/acryl-kv/plugin.json +6 -1
- package/plugins/acryl-kv/src/conformance.ts +226 -0
- package/plugins/acryl-kv/upstream.json +1 -1
- package/plugins/acryl-kv-db/plugin.json +4 -1
- package/plugins/acryl-kv-db/upstream.json +1 -1
- package/plugins/acryl-kv-json/plugin.json +4 -1
- package/plugins/acryl-kv-json/upstream.json +1 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
- package/plugins/acryl-node-server/upstream.json +2 -2
- package/plugins/acryl-router/src/service.ts +3 -0
- package/plugins/acryl-router/upstream.json +2 -2
- package/plugins/acryl-router-http/upstream.json +1 -1
- package/plugins/acryl-state/upstream.json +1 -1
- package/plugins/agent-panel/client/main.ts +159 -0
- package/plugins/agent-panel/index.ts +232 -0
- package/plugins/agent-panel/plugin.json +21 -0
- package/plugins/desktop/upstream.json +1 -1
- package/plugins/desktop-web/upstream.json +1 -1
- package/plugins/extensions/resolve.ts +16 -2
- package/plugins/tsconfig.json +6 -0
- package/shared/DEFAULTS.md +11 -6
- package/shared/SKILL.md +1 -1
- package/templates/agent/blend.yaml +3 -0
- package/templates/agent/src/app.ts +18 -2
- package/templates/agent/src/routes.ts +14 -1
- package/templates/agent/template.json +2 -1
- package/templates/agent/test/agent.test.ts +10 -4
- package/templates/agent/test/app.test.ts +29 -8
- package/templates/agent/test/builder.test.ts +34 -23
- package/templates/agent/test/model.test.ts +24 -19
- package/templates/blank/src/app.ts +25 -5
- package/templates/blank/test/app.test.ts +28 -9
- package/templates/chat/AGENTS.md +16 -0
- package/templates/chat/blend.yaml +32 -0
- package/templates/chat/gitignore +6 -0
- package/templates/chat/migrations/001-chat.sql +12 -0
- package/templates/chat/package.json +33 -0
- package/templates/chat/src/app.ts +107 -0
- package/templates/chat/src/chat-app.ts +101 -0
- package/templates/chat/src/client/chat.tsx +115 -0
- package/templates/chat/src/client/tsconfig.json +26 -0
- package/templates/chat/src/db.ts +43 -0
- package/templates/chat/src/door.ts +7 -0
- package/templates/chat/src/llm.ts +42 -0
- package/templates/chat/src/main.ts +28 -0
- package/templates/chat/src/page.tsx +40 -0
- package/templates/chat/src/routes.ts +15 -0
- package/templates/chat/src/runtime.ts +17 -0
- package/templates/chat/template.json +12 -0
- package/templates/chat/test/chat.test.ts +198 -0
- package/templates/chat/test/server.test.ts +78 -0
- package/templates/chat/tsconfig.json +20 -0
- package/templates/nodejs/AGENTS.md +9 -0
- package/templates/nodejs/blend.yaml +29 -0
- package/templates/nodejs/gitignore +3 -0
- package/templates/nodejs/package.json +30 -0
- package/templates/nodejs/src/app.ts +95 -0
- package/templates/nodejs/src/client/counter.tsx +12 -0
- package/templates/nodejs/src/client/tsconfig.json +26 -0
- package/templates/nodejs/src/door.ts +7 -0
- package/templates/nodejs/src/main.ts +25 -0
- package/templates/nodejs/src/page.tsx +29 -0
- package/templates/nodejs/src/routes.ts +40 -0
- package/templates/nodejs/template.json +11 -0
- package/templates/nodejs/test/app.test.ts +78 -0
- package/templates/nodejs/test/server.test.ts +83 -0
- package/templates/nodejs/tsconfig.json +20 -0
- package/templates/notes/AGENTS.md +26 -0
- package/templates/notes/blend.yaml +32 -0
- package/templates/notes/gitignore +6 -0
- package/templates/notes/migrations/001-notes.sql +18 -0
- package/templates/notes/package.json +33 -0
- package/templates/notes/src/app.ts +107 -0
- package/templates/notes/src/auth.ts +57 -0
- package/templates/notes/src/client/counter.tsx +21 -0
- package/templates/notes/src/client/tsconfig.json +26 -0
- package/templates/notes/src/db.ts +50 -0
- package/templates/notes/src/door.ts +7 -0
- package/templates/notes/src/main.ts +28 -0
- package/templates/notes/src/notes-app.ts +205 -0
- package/templates/notes/src/routes.ts +14 -0
- package/templates/notes/src/runtime.ts +17 -0
- package/templates/notes/src/schema.ts +38 -0
- package/templates/notes/src/views.tsx +136 -0
- package/templates/notes/template.json +12 -0
- package/templates/notes/test/notes.test.ts +280 -0
- package/templates/notes/test/server.test.ts +78 -0
- package/templates/notes/tsconfig.json +20 -0
- package/templates/todo/src/app.ts +27 -7
- package/templates/todo/test/todo.test.ts +47 -22
- package/templates/todo/upstream.json +1 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// The panel in the page: a button at the corner opens a side panel (a shadow root, so the app's CSS cannot touch it and it cannot touch the app's). It asks the app's
|
|
2
|
+
// agent through POST /api/agent-panel/ask, shows the answer as it arrives, and reloads the page after a run that installed or removed a plugin.
|
|
3
|
+
const PREFIX = '/api/agent-panel'
|
|
4
|
+
|
|
5
|
+
interface Session {
|
|
6
|
+
token: string
|
|
7
|
+
drivers: string[]
|
|
8
|
+
}
|
|
9
|
+
interface PanelEvent {
|
|
10
|
+
event: string
|
|
11
|
+
data: Record<string, unknown>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The events of a server-sent stream, as they arrive. */
|
|
15
|
+
async function* events(response: Response): AsyncGenerator<PanelEvent> {
|
|
16
|
+
const reader = response.body!.getReader()
|
|
17
|
+
const decoder = new TextDecoder()
|
|
18
|
+
let buffer = ''
|
|
19
|
+
for (;;) {
|
|
20
|
+
const { value, done } = await reader.read()
|
|
21
|
+
if (done) return
|
|
22
|
+
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/gu, '\n')
|
|
23
|
+
for (let end = buffer.indexOf('\n\n'); end !== -1; end = buffer.indexOf('\n\n')) {
|
|
24
|
+
const block = buffer.slice(0, end)
|
|
25
|
+
buffer = buffer.slice(end + 2)
|
|
26
|
+
let event = 'message'
|
|
27
|
+
let data = ''
|
|
28
|
+
for (const line of block.split('\n')) {
|
|
29
|
+
if (line.startsWith('event:')) event = line.slice(6).trim()
|
|
30
|
+
else if (line.startsWith('data:')) data += line.slice(5).trim()
|
|
31
|
+
}
|
|
32
|
+
if (data !== '') yield { event, data: JSON.parse(data) as Record<string, unknown> }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const css = `
|
|
38
|
+
:host { all: initial }
|
|
39
|
+
* { box-sizing: border-box; font: 14px system-ui, sans-serif }
|
|
40
|
+
button.open { position: fixed; right: 16px; bottom: 16px; z-index: 2147483000; border: 0; border-radius: 24px; padding: 10px 16px; background: #1f2937; color: #fff; cursor: pointer; box-shadow: 0 2px 8px #0004 }
|
|
41
|
+
section { position: fixed; top: 0; right: 0; bottom: 0; width: min(380px, 100vw); z-index: 2147483001; display: none; flex-direction: column; background: #fff; color: #111827; border-left: 1px solid #d1d5db; box-shadow: -2px 0 12px #0003 }
|
|
42
|
+
section.shown { display: flex }
|
|
43
|
+
header { display: flex; gap: 8px; align-items: center; padding: 8px 12px; border-bottom: 1px solid #e5e7eb }
|
|
44
|
+
header strong { flex: 1 }
|
|
45
|
+
#log { flex: 1; overflow: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px }
|
|
46
|
+
.msg { white-space: pre-wrap; padding: 8px 10px; border-radius: 8px; background: #f3f4f6 }
|
|
47
|
+
.me { background: #dbeafe; align-self: flex-end }
|
|
48
|
+
.tool { font: 12px ui-monospace, monospace; color: #4b5563; background: none; padding: 0 }
|
|
49
|
+
.err { background: #fee2e2 }
|
|
50
|
+
form { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border-top: 1px solid #e5e7eb }
|
|
51
|
+
textarea { width: 100%; min-height: 64px; resize: vertical; padding: 8px; border: 1px solid #d1d5db; border-radius: 6px }
|
|
52
|
+
.row { display: flex; gap: 8px; align-items: center }
|
|
53
|
+
.row button, header button { padding: 6px 12px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; cursor: pointer }
|
|
54
|
+
.row button.send { background: #1f2937; color: #fff; border-color: #1f2937 }
|
|
55
|
+
`
|
|
56
|
+
|
|
57
|
+
async function start(): Promise<void> {
|
|
58
|
+
const reply = await fetch(`${PREFIX}/session`)
|
|
59
|
+
if (!reply.ok) return
|
|
60
|
+
const session = (await reply.json()) as Session
|
|
61
|
+
|
|
62
|
+
const host = document.createElement('div')
|
|
63
|
+
host.id = 'agent-panel'
|
|
64
|
+
const shadow = host.attachShadow({ mode: 'open' })
|
|
65
|
+
shadow.innerHTML = `<style>${css}</style>
|
|
66
|
+
<button class="open" type="button">Ask the agent</button>
|
|
67
|
+
<section aria-label="Agent panel">
|
|
68
|
+
<header><strong>Agent</strong><select aria-label="Agent">${session.drivers.map((d) => `<option>${d}</option>`).join('')}</select><button class="close" type="button" aria-label="Close">×</button></header>
|
|
69
|
+
<div id="log" role="log"></div>
|
|
70
|
+
<form><textarea aria-label="Message" placeholder="What should the app do?"></textarea>
|
|
71
|
+
<div class="row"><button class="send" type="submit">Send</button><button class="stop" type="button" disabled>Stop</button></div></form>
|
|
72
|
+
</section>`
|
|
73
|
+
const $ = <T extends Element>(selector: string): T => shadow.querySelector<T>(selector)!
|
|
74
|
+
const panel = $<HTMLElement>('section')
|
|
75
|
+
const log = $<HTMLElement>('#log')
|
|
76
|
+
const input = $<HTMLTextAreaElement>('textarea')
|
|
77
|
+
const send = $<HTMLButtonElement>('.send')
|
|
78
|
+
const stop = $<HTMLButtonElement>('.stop')
|
|
79
|
+
const driver = $<HTMLSelectElement>('select')
|
|
80
|
+
const show = (open: boolean): void => {
|
|
81
|
+
panel.classList.toggle('shown', open)
|
|
82
|
+
if (open) input.focus()
|
|
83
|
+
}
|
|
84
|
+
$('button.open').addEventListener('click', () => show(true))
|
|
85
|
+
$('button.close').addEventListener('click', () => show(false))
|
|
86
|
+
const line = (text: string, kind = ''): HTMLElement => {
|
|
87
|
+
const el = document.createElement('div')
|
|
88
|
+
el.className = `msg ${kind}`
|
|
89
|
+
el.textContent = text
|
|
90
|
+
log.append(el)
|
|
91
|
+
log.scrollTop = log.scrollHeight
|
|
92
|
+
return el
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let controller: AbortController | undefined
|
|
96
|
+
stop.addEventListener('click', () => controller?.abort())
|
|
97
|
+
shadow.querySelector('form')!.addEventListener('submit', async (submitted) => {
|
|
98
|
+
submitted.preventDefault()
|
|
99
|
+
const message = input.value.trim()
|
|
100
|
+
if (message === '' || controller) return
|
|
101
|
+
input.value = ''
|
|
102
|
+
line(message, 'me')
|
|
103
|
+
controller = new AbortController()
|
|
104
|
+
send.disabled = true
|
|
105
|
+
stop.disabled = false
|
|
106
|
+
let changed = false
|
|
107
|
+
let answer: HTMLElement | undefined
|
|
108
|
+
try {
|
|
109
|
+
const response = await fetch(`${PREFIX}/ask`, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'content-type': 'application/json', 'x-agent-panel-token': session.token },
|
|
112
|
+
body: JSON.stringify({ message, driver: driver.value }),
|
|
113
|
+
signal: controller.signal,
|
|
114
|
+
})
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
line(
|
|
117
|
+
((await response.json().catch(() => ({}))) as { error?: string }).error ??
|
|
118
|
+
`The panel answered ${response.status}.`,
|
|
119
|
+
'err',
|
|
120
|
+
)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
for await (const { event, data } of events(response)) {
|
|
124
|
+
if (event === 'text') {
|
|
125
|
+
answer ??= line('')
|
|
126
|
+
answer.textContent += String(data.text ?? '')
|
|
127
|
+
} else if (event === 'tool') {
|
|
128
|
+
line(`${String(data.tool)}${data.error ? ` failed: ${String(data.error)}` : ''}`, 'tool')
|
|
129
|
+
} else if (event === 'change') {
|
|
130
|
+
changed = true
|
|
131
|
+
line(`plugin ${String(data.extension)}: ${String(data.action)}`, 'tool')
|
|
132
|
+
} else if (event === 'error') {
|
|
133
|
+
line(String(data.message ?? 'The run failed.'), 'err')
|
|
134
|
+
} else if (event === 'done') {
|
|
135
|
+
changed = changed || data.changed === true
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch (error) {
|
|
139
|
+
line(
|
|
140
|
+
controller?.signal.aborted
|
|
141
|
+
? 'Stopped.'
|
|
142
|
+
: `The request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
143
|
+
'err',
|
|
144
|
+
)
|
|
145
|
+
} finally {
|
|
146
|
+
controller = undefined
|
|
147
|
+
send.disabled = false
|
|
148
|
+
stop.disabled = true
|
|
149
|
+
}
|
|
150
|
+
// A plugin was installed or removed: the new feature is served by the running app, so the page only needs to be loaded again (unless something is being typed).
|
|
151
|
+
if (changed && input.value.trim() === '') {
|
|
152
|
+
line('Reloading the page to show it.', 'tool')
|
|
153
|
+
setTimeout(() => location.reload(), 900)
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
document.body.append(host)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
void start()
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in agent (spec 049 slice 14): a chat panel inside the running app. The person says what they want; the app's own agent (`driver: 'agent'`) or a coding
|
|
3
|
+
* agent started in the app folder (`'claude'`, `'codex'`, `'pi'`) builds it through the door, and when a plugin was installed or removed the page reloads, so the new feature
|
|
4
|
+
* shows with no restart.
|
|
5
|
+
*
|
|
6
|
+
* - A middleware appends one script tag (`/api/agent-panel/panel.js`) to every HTML response of the routes mounted after it, so no page is edited.
|
|
7
|
+
* - `GET /api/agent-panel/session` gives the page the drivers it may use and a token; `POST /api/agent-panel/ask` runs one message and answers server-sent events:
|
|
8
|
+
* `text`, `tool`, `change`, `done`, `error`.
|
|
9
|
+
* - Only the app's own page may ask (a same-origin request carrying the token), one run at a time, and every ask is written to `.acryl/audit/agent-panel.jsonl`.
|
|
10
|
+
* - Development only: the plugin does nothing with `NODE_ENV=production` or `ACRYL_DOOR=off`.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
13
|
+
import { randomBytes } from 'node:crypto'
|
|
14
|
+
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
17
|
+
import { clientModules } from '../acryl-node-server/src/client-modules.ts'
|
|
18
|
+
import { streamSSE } from '../acryl-router/src/helpers/index.ts'
|
|
19
|
+
import { Acryl } from '../acryl-router/src/index.ts'
|
|
20
|
+
import type {} from '../agent/index.ts'
|
|
21
|
+
import type {} from '../extensions/index.ts'
|
|
22
|
+
|
|
23
|
+
export const name = 'agent-panel'
|
|
24
|
+
export const inject = ['router', 'agent', 'extensions', 'appInstance']
|
|
25
|
+
|
|
26
|
+
const PREFIX = '/api/agent-panel'
|
|
27
|
+
const CLIENT_DIR = join(import.meta.dirname, 'client')
|
|
28
|
+
|
|
29
|
+
export type DriverName = 'agent' | 'claude' | 'codex' | 'pi'
|
|
30
|
+
|
|
31
|
+
export interface PanelConfig {
|
|
32
|
+
/** Driver programs to offer besides the app's own agent; default: the ones found on the machine. */
|
|
33
|
+
drivers?: readonly Exclude<DriverName, 'agent'>[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The command line of each coding agent started headless in the app folder (the same ones the product test uses). */
|
|
37
|
+
function command(
|
|
38
|
+
driver: Exclude<DriverName, 'agent'>,
|
|
39
|
+
message: string,
|
|
40
|
+
root: string,
|
|
41
|
+
): { file: string; args: string[] } {
|
|
42
|
+
switch (driver) {
|
|
43
|
+
case 'claude':
|
|
44
|
+
return {
|
|
45
|
+
file: 'claude',
|
|
46
|
+
args: [
|
|
47
|
+
'-p',
|
|
48
|
+
message,
|
|
49
|
+
'--permission-mode',
|
|
50
|
+
'acceptEdits',
|
|
51
|
+
'--allowedTools',
|
|
52
|
+
'Read,Edit,Write,Glob,Grep,Bash(npm *),Bash(npx *),Bash(node *),Bash(.acryl/bin/acryl *),Bash(curl *)',
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
case 'codex':
|
|
56
|
+
return {
|
|
57
|
+
file: 'codex',
|
|
58
|
+
args: [
|
|
59
|
+
'exec',
|
|
60
|
+
'--skip-git-repo-check',
|
|
61
|
+
'-s',
|
|
62
|
+
'workspace-write',
|
|
63
|
+
'-c',
|
|
64
|
+
'sandbox_workspace_write.network_access=true',
|
|
65
|
+
'-C',
|
|
66
|
+
root,
|
|
67
|
+
message,
|
|
68
|
+
],
|
|
69
|
+
}
|
|
70
|
+
case 'pi':
|
|
71
|
+
return {
|
|
72
|
+
file: 'pi',
|
|
73
|
+
args: [
|
|
74
|
+
'-p',
|
|
75
|
+
message,
|
|
76
|
+
'--provider',
|
|
77
|
+
'deepseek',
|
|
78
|
+
'--model',
|
|
79
|
+
'deepseek-v4-pro',
|
|
80
|
+
'--no-session',
|
|
81
|
+
],
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function onPath(program: string): boolean {
|
|
87
|
+
const found = spawnSync(process.platform === 'win32' ? 'where' : 'which', [program], {
|
|
88
|
+
encoding: 'utf8',
|
|
89
|
+
})
|
|
90
|
+
return found.status === 0
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The drivers this machine can offer: the app's own agent always; a program when it is installed (pi also needs its key in the environment). */
|
|
94
|
+
export function availableDrivers(wanted?: readonly Exclude<DriverName, 'agent'>[]): DriverName[] {
|
|
95
|
+
const found: DriverName[] = ['agent']
|
|
96
|
+
for (const driver of wanted ?? (['claude', 'codex', 'pi'] as const)) {
|
|
97
|
+
if (driver === 'pi' && !process.env.DEEPSEEK_API_KEY) continue
|
|
98
|
+
if (onPath(driver)) found.push(driver)
|
|
99
|
+
}
|
|
100
|
+
return found
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Whether a request comes from the app's own page: a browser says so itself (`Sec-Fetch-Site: same-origin`), or the Origin is the Host. */
|
|
104
|
+
export function fromOwnPage(headers: Headers): boolean {
|
|
105
|
+
const site = headers.get('sec-fetch-site')
|
|
106
|
+
if (site !== null) return site === 'same-origin'
|
|
107
|
+
const origin = headers.get('origin')
|
|
108
|
+
const host = headers.get('host')
|
|
109
|
+
return origin !== null && host !== null && new URL(origin).host === host
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
113
|
+
if (process.env.NODE_ENV === 'production' || process.env.ACRYL_DOOR === 'off') return
|
|
114
|
+
const root = ctx.appInstance.root
|
|
115
|
+
const token = randomBytes(24).toString('hex')
|
|
116
|
+
const auditFile = join(root, '.acryl', 'audit', 'agent-panel.jsonl')
|
|
117
|
+
const audit = (entry: Record<string, unknown>): void => {
|
|
118
|
+
try {
|
|
119
|
+
mkdirSync(dirname(auditFile), { recursive: true })
|
|
120
|
+
appendFileSync(auditFile, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`)
|
|
121
|
+
} catch {
|
|
122
|
+
// The audit never stops a run.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const drivers = availableDrivers(config.drivers)
|
|
126
|
+
let running = false
|
|
127
|
+
|
|
128
|
+
const tag = `<script type="module" src="${PREFIX}/panel.js"></script>`
|
|
129
|
+
const app = new Acryl()
|
|
130
|
+
// The script tag, on every HTML page the routes mounted after this plugin answer.
|
|
131
|
+
.use('*', async (c, next) => {
|
|
132
|
+
await next()
|
|
133
|
+
if (!(c.res.headers.get('content-type') ?? '').includes('text/html')) return
|
|
134
|
+
const html = await c.res.text()
|
|
135
|
+
const headers = new Headers(c.res.headers)
|
|
136
|
+
headers.delete('content-length')
|
|
137
|
+
c.res = new Response(
|
|
138
|
+
html.includes('</body>') ? html.replace('</body>', `${tag}</body>`) : html + tag,
|
|
139
|
+
{ status: c.res.status, headers },
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
.get(`${PREFIX}/panel.js`, (c) =>
|
|
143
|
+
c.body(`import '${PREFIX}/client/main.ts'\n`, 200, {
|
|
144
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
145
|
+
'cache-control': 'no-cache',
|
|
146
|
+
}),
|
|
147
|
+
)
|
|
148
|
+
.route(`${PREFIX}/client`, clientModules({ root: CLIENT_DIR }))
|
|
149
|
+
.get(`${PREFIX}/session`, (c) => {
|
|
150
|
+
if (!fromOwnPage(c.req.raw.headers))
|
|
151
|
+
return c.json({ error: "only the app's own page may use the panel" }, 403)
|
|
152
|
+
return c.json({ token, drivers })
|
|
153
|
+
})
|
|
154
|
+
.post(`${PREFIX}/ask`, async (c) => {
|
|
155
|
+
if (!fromOwnPage(c.req.raw.headers) || c.req.header('x-agent-panel-token') !== token)
|
|
156
|
+
return c.json({ error: "only the app's own page may ask" }, 403)
|
|
157
|
+
const body = (await c.req.json().catch(() => ({}))) as { message?: unknown; driver?: unknown }
|
|
158
|
+
const message = typeof body.message === 'string' ? body.message.trim() : ''
|
|
159
|
+
const driver = (typeof body.driver === 'string' ? body.driver : 'agent') as DriverName
|
|
160
|
+
if (message === '' || message.length > 8000)
|
|
161
|
+
return c.json({ error: 'write a message of at most 8000 characters' }, 400)
|
|
162
|
+
if (!drivers.includes(driver))
|
|
163
|
+
return c.json(
|
|
164
|
+
{ error: `the driver "${driver}" is not available here (${drivers.join(', ')})` },
|
|
165
|
+
400,
|
|
166
|
+
)
|
|
167
|
+
if (running) return c.json({ error: 'a run is already going; wait for it to end' }, 409)
|
|
168
|
+
running = true
|
|
169
|
+
return streamSSE(c, async (stream) => {
|
|
170
|
+
const send = (event: string, data: unknown): Promise<void> =>
|
|
171
|
+
stream.writeSSE({ event, data: JSON.stringify(data) })
|
|
172
|
+
let changed = false
|
|
173
|
+
const off = ctx.on('extensions/changed', (extension: string, action: string) => {
|
|
174
|
+
changed = true
|
|
175
|
+
void send('change', { extension, action })
|
|
176
|
+
})
|
|
177
|
+
audit({ ask: 'start', driver, characters: message.length })
|
|
178
|
+
try {
|
|
179
|
+
if (driver === 'agent') {
|
|
180
|
+
const result = await ctx.agent.run(message)
|
|
181
|
+
for (const call of result.trace)
|
|
182
|
+
await send('tool', {
|
|
183
|
+
tool: call.tool,
|
|
184
|
+
input: call.input,
|
|
185
|
+
...(call.error ? { error: call.error } : { result: call.result }),
|
|
186
|
+
})
|
|
187
|
+
await send('text', {
|
|
188
|
+
text:
|
|
189
|
+
result.status === 'completed'
|
|
190
|
+
? result.output
|
|
191
|
+
: result.status === 'cancelled'
|
|
192
|
+
? 'The run was cancelled.'
|
|
193
|
+
: `The run stopped after ${result.steps} steps without an answer.`,
|
|
194
|
+
})
|
|
195
|
+
} else {
|
|
196
|
+
const { file, args } = command(driver, message, root)
|
|
197
|
+
const child = spawn(file, args, {
|
|
198
|
+
cwd: root,
|
|
199
|
+
env: process.env,
|
|
200
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
201
|
+
})
|
|
202
|
+
stream.onAbort(() => {
|
|
203
|
+
child.kill('SIGTERM')
|
|
204
|
+
})
|
|
205
|
+
child.stdout.on(
|
|
206
|
+
'data',
|
|
207
|
+
(chunk: Buffer) => void send('text', { text: chunk.toString() }),
|
|
208
|
+
)
|
|
209
|
+
child.stderr.on(
|
|
210
|
+
'data',
|
|
211
|
+
(chunk: Buffer) =>
|
|
212
|
+
void send('tool', { tool: driver, info: chunk.toString().slice(0, 500) }),
|
|
213
|
+
)
|
|
214
|
+
const code = await new Promise<number>((done) => {
|
|
215
|
+
child.on('error', () => done(1))
|
|
216
|
+
child.on('exit', (exit) => done(exit ?? 1))
|
|
217
|
+
})
|
|
218
|
+
if (code !== 0)
|
|
219
|
+
await send('error', { message: `${driver} ended with exit code ${code}` })
|
|
220
|
+
}
|
|
221
|
+
await send('done', { changed })
|
|
222
|
+
} catch (error) {
|
|
223
|
+
await send('error', { message: error instanceof Error ? error.message : String(error) })
|
|
224
|
+
} finally {
|
|
225
|
+
off()
|
|
226
|
+
running = false
|
|
227
|
+
audit({ ask: 'end', driver, changed })
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
ctx.router.route('/', app)
|
|
232
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent-panel",
|
|
3
|
+
"description": "A chat panel inside the running app: the person writes what they want, the app's own agent (or Claude Code, Codex or pi started in the app folder) builds it through the same door, and the page reloads when a plugin was installed, with no restart. Development only; opt-in with acrylblends add agent-panel.",
|
|
4
|
+
"requires": [
|
|
5
|
+
"acryl-router",
|
|
6
|
+
"acryl-node-server",
|
|
7
|
+
"agent",
|
|
8
|
+
"builder",
|
|
9
|
+
"extensions",
|
|
10
|
+
"sandbox",
|
|
11
|
+
"tasks",
|
|
12
|
+
"instance"
|
|
13
|
+
],
|
|
14
|
+
"inject": ["router", "agent", "extensions", "appInstance"],
|
|
15
|
+
"provides": [],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@deepseek-ai/cordis": "4.0.4"
|
|
18
|
+
},
|
|
19
|
+
"mount": "add a row `- id: agent-panel` / `name: agent-panel` to blend.yaml BEFORE the `routes` row (its middleware adds one script tag to every HTML page the routes after it answer) and register it in src/app.ts: 'agent-panel': panel; open the app in a browser and use the button at the corner of the page",
|
|
20
|
+
"gitignore": [".acryl/"]
|
|
21
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"apps/acryl-desktop/src/shell/desktop-user-data.ts": "user-data.ts",
|
|
6
6
|
"apps/acryl-desktop/src/shell/shutdown.ts": "shutdown.ts"
|
|
7
7
|
},
|
|
8
|
-
"commit": "
|
|
8
|
+
"commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
|
|
9
9
|
},
|
|
10
10
|
"rewrittenSpecifiers": 0,
|
|
11
11
|
"digest": "sha256:7dfe960a8bbb476c35540d2202a7aaa62b33492c2ac3e34314e2ecfca45f32f2"
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"apps/acryl-desktop/src/shell/desktop-user-data.ts": "user-data.ts",
|
|
6
6
|
"apps/acryl-desktop/src/shell/shutdown.ts": "shutdown.ts"
|
|
7
7
|
},
|
|
8
|
-
"commit": "
|
|
8
|
+
"commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
|
|
9
9
|
},
|
|
10
10
|
"rewrittenSpecifiers": 0,
|
|
11
11
|
"digest": "sha256:7dfe960a8bbb476c35540d2202a7aaa62b33492c2ac3e34314e2ecfca45f32f2"
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* (ACRYL needed the same fallback on Deno); the returned function removes the hook.
|
|
6
6
|
*
|
|
7
7
|
* The framework plugins are not packages in an app (they are source under `plugins/`), so `aliases` names the files a framework specifier such as
|
|
8
|
-
* `acryl-router` stands for. A staged plugin then imports the same module the app itself runs, so `App` is the very class the router mounts.
|
|
8
|
+
* `acryl-router` stands for (and the ports and providers beside it: `acryl-kv`, `acryl-kv-json`, `acryl-db`, ..., and each as `#acryl-kv` ... the name the app's `package.json` "imports" gives them). A staged plugin then imports the same module the app itself runs, so `App` is the very class the router mounts.
|
|
9
9
|
*/
|
|
10
|
-
import { existsSync, mkdirSync, realpathSync } from 'node:fs'
|
|
10
|
+
import { existsSync, mkdirSync, readdirSync, realpathSync } from 'node:fs'
|
|
11
11
|
import { registerHooks } from 'node:module'
|
|
12
12
|
import { join } from 'node:path'
|
|
13
13
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
@@ -90,6 +90,20 @@ export function routerAliases(): Record<string, string> {
|
|
|
90
90
|
add('acryl-router', 'index.ts')
|
|
91
91
|
add('acryl-router/middleware', 'middleware/index.ts')
|
|
92
92
|
add('acryl-router/helpers', 'helpers/index.ts')
|
|
93
|
+
// The ports and their providers sit beside the router as source too (`acrylblends add kv` copies `acryl-kv` and `acryl-kv-json`). A plugin installed live, a
|
|
94
|
+
// provider of your own for example, imports them by name and gets the very modules the app runs.
|
|
95
|
+
const plugins = new URL('../', import.meta.url)
|
|
96
|
+
for (const folder of readdirSync(fileURLToPath(plugins), { withFileTypes: true })) {
|
|
97
|
+
if (!folder.isDirectory() || !folder.name.startsWith('acryl-')) continue
|
|
98
|
+
const entry = fileURLToPath(new URL(`${folder.name}/src/index.ts`, plugins))
|
|
99
|
+
if (!existsSync(entry)) continue
|
|
100
|
+
// The bare name, and the app's own `#name` (a `package.json` "imports" entry that `acrylblends add` writes): a staged copy lives in the app's home, which may be
|
|
101
|
+
// outside the app folder, where Node would not find that entry.
|
|
102
|
+
aliases[folder.name] ??= entry
|
|
103
|
+
aliases[`#${folder.name}`] = entry
|
|
104
|
+
const suite = fileURLToPath(new URL(`${folder.name}/src/conformance.ts`, plugins))
|
|
105
|
+
if (existsSync(suite)) aliases[`#${folder.name}/conformance`] = suite
|
|
106
|
+
}
|
|
93
107
|
return aliases
|
|
94
108
|
}
|
|
95
109
|
|
package/plugins/tsconfig.json
CHANGED
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
"acryl-db-sqlite/tests/loader.spec.ts",
|
|
27
27
|
"acryl-kv-json/tests/loader.spec.ts",
|
|
28
28
|
"acryl-kv-db/tests/loader.spec.ts",
|
|
29
|
+
"acryl-door/tests/harness.ts",
|
|
30
|
+
"acryl-door/tests/host/loader.spec.ts",
|
|
31
|
+
"acryl-door/tests/host/door.spec.ts",
|
|
32
|
+
"acryl-door/tests/host/shim.spec.ts",
|
|
33
|
+
"acryl-door/tests/host/verbs.spec.ts",
|
|
34
|
+
"acryl-door/tests/page",
|
|
29
35
|
"acryl-state/src/react.ts",
|
|
30
36
|
"acryl-state/tests/react.spec.ts",
|
|
31
37
|
"acryl-router-http/tests/adapter.spec.ts",
|
package/shared/DEFAULTS.md
CHANGED
|
@@ -6,8 +6,8 @@ and swap it at the one place that mounts it.
|
|
|
6
6
|
|
|
7
7
|
| Choice | Default | To swap it |
|
|
8
8
|
| --- | --- | --- |
|
|
9
|
-
| Records |
|
|
10
|
-
| Small state |
|
|
9
|
+
| Records | the `db` port on SQLite (already mounted in the notes and chat templates; elsewhere `npx acrylblends add db`) | mount another provider of `db` in `src/app.ts`; plugins use `ctx.db` and do not change |
|
|
10
|
+
| Small state | the `kv` port on one JSON file (`npx acrylblends add kv`) | mount `acryl-kv-db` (the same port over `db`) or a provider of your own |
|
|
11
11
|
| Server | Node (`acryl-node-server`) | mount the adapter of another runtime in `src/app.ts` |
|
|
12
12
|
| Window | the browser | `npx acrylblends add desktop` adds an Electron window on the same server |
|
|
13
13
|
|
|
@@ -23,11 +23,11 @@ install a second time). Do not create `src/plugins/` or `extensions/`.
|
|
|
23
23
|
|
|
24
24
|
## The door
|
|
25
25
|
|
|
26
|
-
The door is a command in this folder, `.acryl/bin/acryl`, that lets you look at and change the running app
|
|
26
|
+
The door is a command in this folder, `.acryl/bin/acryl`, that lets you look at and change the running app. The running app writes it, and it reaches only the app running from this folder. Its state (a secret and an audit log) lives under `.acryl/`, which git ignores: never commit it. It is on in development and off with `NODE_ENV=production` or `ACRYL_DOOR=off`; one process at a time serves a folder's door, so a second `npm start` in the same folder stops with a message that says so (the tests set `ACRYL_DOOR=off`). If `.acryl/bin/acryl` is not here, the app is not running: start it with `npm run dev` (in the background) and open its address in a browser.
|
|
27
27
|
|
|
28
28
|
```
|
|
29
|
-
.acryl/bin/acryl brief what this app is: its plugins, where the docs are, the verbs
|
|
30
|
-
.acryl/bin/acryl look
|
|
29
|
+
.acryl/bin/acryl brief what this app is: its plugins, where the docs are, the verbs, and whether a page is open
|
|
30
|
+
.acryl/bin/acryl look the page as text, with refs (1.1, 1.2, ...): roles, names, values, visible text
|
|
31
31
|
.acryl/bin/acryl act click --ref <r>
|
|
32
32
|
.acryl/bin/acryl act type --ref <r> --text <t> [--submit] [--no-clear]
|
|
33
33
|
.acryl/bin/acryl act select --ref <r> --option <o>
|
|
@@ -36,5 +36,10 @@ The door is a command in this folder, `.acryl/bin/acryl`, that lets you look at
|
|
|
36
36
|
.acryl/bin/acryl act wait (--text <t> | --role <role> [--name <n>]) [--gone] [--timeout-ms <ms>]
|
|
37
37
|
.acryl/bin/acryl plugin list | add <folder> | remove <name> | reload
|
|
38
38
|
.acryl/bin/acryl tool list | call --tool <name> --args '<json>'
|
|
39
|
+
.acryl/bin/acryl verbs the verb list
|
|
39
40
|
```
|
|
40
|
-
|
|
41
|
+
|
|
42
|
+
- `look` and `act` need the app's page open in a browser (`brief` says `Page: not open` when it is not). Look again before each `act`: refs belong to the latest `look`.
|
|
43
|
+
- A password field is not shown and cannot be typed into through the door; sign in by hand, or test the sign-in with a test.
|
|
44
|
+
- Every verb takes `--json`. The exit code is 0 for done, 1 when the verb failed, 2 for a usage error and 3 when no app is running in this folder.
|
|
45
|
+
- `plugin add plugins/<name>` checks the plugin, installs it and makes it live without a restart (the `extensions` plugin does this; every template that has the door has it, in development only: it is left out with `NODE_ENV=production`). `tool call` reaches only the tools on the door's allowlist (empty by default). On Windows the command is `.acryl\bin\acryl.cmd`.
|
package/shared/SKILL.md
CHANGED
|
@@ -10,5 +10,5 @@ description: How to change, run and check the __APP_NAME__ app. Use it before ad
|
|
|
10
10
|
2. **Make the change small.** Your own routes are a plugin (`src/routes.ts`) that builds an `Acryl` app and mounts it; test a route with `await app.request('/path')`.
|
|
11
11
|
3. **One plugin folder.** A plugin lives in `plugins/<name>/` (`npx acrylblends add <name>` copies a library plugin there). There is no `src/plugins/` and no `extensions/`.
|
|
12
12
|
4. **Defaults are swappable.** The database, the key-value store, the server and the window are plugins behind ports. The table "Defaults you can swap" in `AGENTS.md` names each default and where it is swapped. Never reach around a port to a driver: `npx acrylblends check-deps` fails the check.
|
|
13
|
-
5. **Look at the running app.**
|
|
13
|
+
5. **Look at the running app.** Start it (`npm run dev`, in the background), open its address in a browser, then run `.acryl/bin/acryl brief`, `look` and `act`: the door shows the page as text with refs and clicks or types on it. Read the section "The door" of `AGENTS.md` for the verbs. Without the door, check the page by hand.
|
|
14
14
|
6. **Done means checked.** `npm test` passes, and the change shows in the running app after a restart. No secret goes in a file or in `git status`: keys come from environment variables.
|
|
@@ -54,3 +54,6 @@ spec:
|
|
|
54
54
|
name: acryl-node-server
|
|
55
55
|
config:
|
|
56
56
|
hostname: "{{parameters.hostname}}"
|
|
57
|
+
# The door: an agent working in this folder looks at the page and acts on it through .acryl/bin/acryl. Development only: src/app.ts leaves it out when NODE_ENV=production.
|
|
58
|
+
- id: acryl-door
|
|
59
|
+
name: acryl-door
|
|
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
|
5
|
+
import * as door from '../plugins/acryl-door/src/index.ts'
|
|
5
6
|
import * as nodeServer from '../plugins/acryl-node-server/src/index.ts'
|
|
6
7
|
import * as router from '../plugins/acryl-router/src/index.ts'
|
|
7
8
|
import * as state from '../plugins/acryl-state/src/index.ts'
|
|
@@ -36,6 +37,7 @@ export const registry = {
|
|
|
36
37
|
sandbox: sandboxPlugin,
|
|
37
38
|
routes,
|
|
38
39
|
'acryl-node-server': nodeServer,
|
|
40
|
+
'acryl-door': door,
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
/** The manifest next to this app's source. */
|
|
@@ -55,6 +57,7 @@ export function readManifest(values?: Readonly<Record<string, ParameterValue>>)
|
|
|
55
57
|
|
|
56
58
|
export interface App {
|
|
57
59
|
readonly root: Context
|
|
60
|
+
/** The address the server listens on; empty when the app was made with `listen: false`. */
|
|
58
61
|
readonly url: string
|
|
59
62
|
stop(): Promise<void>
|
|
60
63
|
}
|
|
@@ -69,9 +72,22 @@ export async function createApp(
|
|
|
69
72
|
values?: Readonly<Record<string, ParameterValue>>
|
|
70
73
|
/** A real model (any OpenAI-compatible API) in place of the offline scripted one. */
|
|
71
74
|
model?: OpenAIConfig
|
|
75
|
+
/** 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. */
|
|
76
|
+
listen?: boolean
|
|
72
77
|
} = {},
|
|
73
78
|
): Promise<App> {
|
|
74
|
-
const
|
|
79
|
+
const read = readManifest(options.values)
|
|
80
|
+
// 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
|
|
81
|
+
// serves a folder's door, so the tests that start several apps set it).
|
|
82
|
+
const listen = options.listen !== false
|
|
83
|
+
const leaveOut = new Set<string>()
|
|
84
|
+
if (!listen) leaveOut.add('acryl-node-server').add('acryl-door')
|
|
85
|
+
if (process.env.NODE_ENV === 'production' || process.env.ACRYL_DOOR === 'off')
|
|
86
|
+
leaveOut.add('acryl-door')
|
|
87
|
+
const manifest = {
|
|
88
|
+
...read,
|
|
89
|
+
plugins: read.plugins.map((row) => (leaveOut.has(row.use) ? { ...row, disabled: true } : row)),
|
|
90
|
+
}
|
|
75
91
|
const extensionsDir = join(chosen.root, 'plugins')
|
|
76
92
|
// 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.
|
|
77
93
|
const legacyDir = join(chosen.root, 'extensions')
|
|
@@ -103,7 +119,7 @@ export async function createApp(
|
|
|
103
119
|
]
|
|
104
120
|
return {
|
|
105
121
|
root,
|
|
106
|
-
url: root.nodeServer.url,
|
|
122
|
+
url: listen ? root.nodeServer.url : '',
|
|
107
123
|
async stop() {
|
|
108
124
|
for (const fiber of [...fibers].reverse()) await fiber.dispose()
|
|
109
125
|
},
|
|
@@ -7,6 +7,12 @@ import type {} from '../plugins/tasks/index.ts'
|
|
|
7
7
|
|
|
8
8
|
export const APP_NAME = '__APP_NAME__'
|
|
9
9
|
|
|
10
|
+
/** 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. */
|
|
11
|
+
const DOOR_TAG =
|
|
12
|
+
process.env.NODE_ENV !== 'production' && process.env.ACRYL_DOOR !== 'off'
|
|
13
|
+
? '<script type="module" src="/api/acryl-door/page.js"></script>'
|
|
14
|
+
: ''
|
|
15
|
+
|
|
10
16
|
/**
|
|
11
17
|
* The app's own routes on the router: `POST /ask` for the agent; `GET /extensions` and `GET /tasks` to observe what it did (every run is a
|
|
12
18
|
* durable task owning one task per tool call); `POST /tasks/cancel`. What changes is also streamed: `GET /api/state/tasks/events` and
|
|
@@ -25,7 +31,14 @@ export const routes = {
|
|
|
25
31
|
}
|
|
26
32
|
const app = new Acryl()
|
|
27
33
|
.use(bodyLimit({ maxSize: 64 * 1024 }))
|
|
28
|
-
|
|
34
|
+
// 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.
|
|
35
|
+
.get('/', (c) =>
|
|
36
|
+
(c.req.header('accept') ?? '').includes('text/html')
|
|
37
|
+
? c.html(
|
|
38
|
+
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title></head><body><h1>${APP_NAME}</h1><p>This app runs an agent. Ask it for a feature (<code>POST /ask</code>), or add the chat panel with <code>npx acrylblends add agent-panel</code>.</p>${DOOR_TAG}</body></html>`,
|
|
39
|
+
)
|
|
40
|
+
: c.json({ app: APP_NAME, ok: true }),
|
|
41
|
+
)
|
|
29
42
|
.get('/health', (c) => c.json({ ok: true }))
|
|
30
43
|
.get('/extensions', (c) =>
|
|
31
44
|
c.json({
|