acrylblends 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -15
- package/bin/acrylblends.js +9 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +136 -5
- package/dist/index.js.map +1 -1
- package/docs/framework/README.md +1 -0
- package/docs/framework/agents/index.md +18 -0
- package/docs/framework/cordis/migration-to-1-0.md +4 -3
- package/docs/framework/getting-started/desktop.md +20 -0
- package/docs/framework/guides/create-acrylblends.md +21 -0
- package/docs/framework/guides/data.md +12 -0
- package/docs/framework/guides/production.md +11 -1
- package/docs/framework/workshop/README.md +381 -0
- package/docs/framework/workshop/facilitator.md +107 -0
- package/lib/cordis/db-sqlite.js +10 -2
- package/lib/cordis/kv-json.js +10 -2
- package/lib/cordis/node-server.js +1 -1
- package/lib/cordis/router-http.js +1 -1
- package/lib/node-server.js +1 -1
- package/lib/{test-websocket-ZXHNHQSU.js → test-websocket-D8WWqE5G.js} +1 -2
- package/package.json +1 -1
- package/plugins/acryl-db-sqlite/plugin.json +1 -1
- package/plugins/acryl-db-sqlite/src/index.ts +8 -1
- package/plugins/acryl-kv-json/plugin.json +1 -1
- package/plugins/acryl-kv-json/src/index.ts +8 -1
- package/plugins/acryl-node-server/src/adapter/request.ts +3 -2
- package/plugins/agent-panel/headless.ts +24 -2
- package/plugins/desktop/launch.ts +48 -0
- package/plugins/desktop/plugin.json +1 -1
- package/plugins/desktop-web/launch.ts +48 -0
- package/plugins/desktop-web/plugin.json +1 -1
- package/templates/chat/AGENTS.md +1 -1
- package/templates/chat/src/access.ts +110 -0
- package/templates/chat/src/chat-app.ts +7 -1
- package/templates/chat/src/routes.ts +5 -1
- package/templates/chat/test/chat.test.ts +65 -0
- package/templates/notes/test/notes.test.ts +1 -1
- package/templates/todo/src/main.ts +2 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Facilitator notes: the 90-minute ACRYL Blends workshop
|
|
2
|
+
|
|
3
|
+
Read the attendee handout first (`docs/framework/workshop/README.md`); this page is the room around it: what to put on each machine, what actually breaks, and
|
|
4
|
+
where to cut when the clock runs. Every timing and every failure below was measured on the published `acrylblends` 1.0.0 during the readiness walk
|
|
5
|
+
(`docs/plans/workshop-readiness.md`).
|
|
6
|
+
|
|
7
|
+
## Before the room: put this on every machine
|
|
8
|
+
|
|
9
|
+
| What | Why | How |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| Node.js 22.19 or newer (24 LTS or 26 tested), npm 11 | the app runs TypeScript directly, no build step | `node -v`; if missing, the LTS from nodejs.org or `brew install node` (Node 26 today, which is tested) |
|
|
12
|
+
| git | the registry block clones the registry | `git --version` |
|
|
13
|
+
| A warm npm cache | the first `npm create` downloads the initializer, `npm install` downloads Electron | create one app and add the desktop shell once per machine (below) |
|
|
14
|
+
| A coding agent, logged in | step 7 drives Codex or Claude Code through the panel | `codex login status` or run `claude` once |
|
|
15
|
+
| `~/acryl-workshop/prepared/` | the no-network fallback and the reset | copy the two prepared apps (below) |
|
|
16
|
+
| A GitHub account with `gh auth login` | step 10 forks the workshop registry and opens a pull request | `gh auth status` |
|
|
17
|
+
| A device with `git` and a text editor | the edits in steps 2, 4 and 8 are small but real | `git --version` |
|
|
18
|
+
|
|
19
|
+
Warm-up, once per machine (this is also the exact smoke test that the room works):
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
mkdir -p ~/acryl-workshop && cd ~/acryl-workshop
|
|
23
|
+
npm create acrylblends@latest warmup notes
|
|
24
|
+
cd warmup && npm install && npm test
|
|
25
|
+
npx acrylblends add desktop && npm install
|
|
26
|
+
cd ~/acryl-workshop && cp -R warmup prepared-notes && rm -rf warmup
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
That leaves `prepared-notes` with everything installed, including Electron.
|
|
30
|
+
|
|
31
|
+
## Timings, checkpoints, and what to make optional
|
|
32
|
+
|
|
33
|
+
The handout's own timings, as measured on the published 1.0.2 (macOS, cold npm cache in a throwaway home):
|
|
34
|
+
|
|
35
|
+
| Minutes | Step | Measured | If late |
|
|
36
|
+
| --- | --- | --- | --- |
|
|
37
|
+
| 0-10 | installs and checks | seconds each | never cut |
|
|
38
|
+
| 10-15 | create and run the blank app | create 9 s cold (2 s warm), install 2 s, boot 2 s | never cut |
|
|
39
|
+
| 15-22 | change one route | reload is instant | never cut |
|
|
40
|
+
| 22-30 | tests and the typecheck | test 0.5-2 s, check 2.5-4 s | keep; cut the "write a test" exercise |
|
|
41
|
+
| 30-38 | data: JSON then SQLite (**optional**) | two adds plus edits, ~8 min | **cut first** |
|
|
42
|
+
| 38-45 | the desktop window | add 2 s, install 3 s, first Electron boot ~20 s | pre-warm Electron on every machine or demo it |
|
|
43
|
+
| 45-53 | pull a starter | create 4 s, install 3 s, runs at once | keep; it is the fastest win in the room |
|
|
44
|
+
| 53-60 | create from a template | create + install + add desktop 4 s | drop the second app |
|
|
45
|
+
| 60-75 | the agent builds a feature | set-up 4 s; codex wrote a page and tests in about 7 minutes | never cut; demo it if the room is behind |
|
|
46
|
+
| 75-82 | upgrade | seconds: a fresh app answers "everything is up to date" | keep; it is a 2-minute talk |
|
|
47
|
+
| 82-90 | publish and the hero moment | the registry steps are seconds; the PR is talk | always keep at least the hero talk |
|
|
48
|
+
|
|
49
|
+
Checkpoints in the handout, and what to look for as you walk the room: **15 minutes** every screen shows `{"app":"my-hero","ok":true}`; **45 minutes**
|
|
50
|
+
every screen has the app in a desktop window; **75 minutes** every screen has `/board` open with the agent's page. A machine behind at a checkpoint is
|
|
51
|
+
what the optional column is for, and the handout names the same choices so nobody has to guess.
|
|
52
|
+
|
|
53
|
+
## What breaks in the room, and what to say
|
|
54
|
+
|
|
55
|
+
These are the defects found by the walk. They are known; say so rather than letting people think they broke something.
|
|
56
|
+
|
|
57
|
+
- **The data step is the slow one.** Wiring `kv` then `db` by hand is about ten minutes of edits for a newcomer (two rows, two registry lines, two
|
|
58
|
+
type imports, the migrations folder). It is the block the handout names optional, and the one to help with first.
|
|
59
|
+
- **A relative migrations path in `blend.yaml` fails.** `migrations: migrations` in the row stops the app with "this app has no file base URL to
|
|
60
|
+
resolve it from". The folder is named in `src/app.ts`'s compose options instead; the handout teaches that form and the stuck line names the error.
|
|
61
|
+
- **`pi` shows up only when the app can see its login.** 1.0.2 offers pi as a chat driver when `DEEPSEEK_API_KEY` is exported or `~/.pi/agent/auth.json`
|
|
62
|
+
holds a deepseek entry, read from the HOME the app runs in. On a normal attendee machine it is there; in a run with a moved HOME it is not (and pi
|
|
63
|
+
would fail on its first message anyway). `pty:pi` needs only the program.
|
|
64
|
+
- **The agent run takes minutes, not seconds.** Codex wrote the page and its tests in about seven; tell the room to start it and keep talking.
|
|
65
|
+
- **Publishing.** The workshop registry is `github.com/acryldev/workshop-2026-11-20`, public and open. Attendees fork it, run
|
|
66
|
+
`node scripts/make-entry.mjs ../agent-app <login>.agent-app --description "..."`, push and open a pull request; a check merges it, usually well under
|
|
67
|
+
two minutes, and a refusal comes as a comment. Taking an app down is a pull request that removes its folder. Pulling is
|
|
68
|
+
`--from <id> --registry github:acryldev/workshop-2026-11-20` (rehearsed on 1.0.2: create 12 s, install 3 s, runs at once).
|
|
69
|
+
- **Browser-only attendees (TODO(M9)).** The handout's hosted-workspace stub is empty until M9 is live; until then a browser-only attendee can follow
|
|
70
|
+
along but not create an app.
|
|
71
|
+
- **node-pty's install script (C5).** `npm install` warns that npm 11 blocked `node-pty`'s script. The terminal drivers still list; the workshop does
|
|
72
|
+
not open one.
|
|
73
|
+
|
|
74
|
+
## The no-network fallback
|
|
75
|
+
|
|
76
|
+
If the venue has no reliable network:
|
|
77
|
+
|
|
78
|
+
1. Every machine gets `prepared-notes` and a prepared agent app from the sheet above, so steps 1-6 skip creation and installs.
|
|
79
|
+
2. Step 8 uses the cloned registry and the installed `blends-core`; no network needed.
|
|
80
|
+
3. Step 7 needs the agent's own model, so it cannot run offline. Demo it from your machine on a tethered connection, or record it (a 4-minute
|
|
81
|
+
screen recording of the codex run is enough).
|
|
82
|
+
4. Keep one machine on a phone hotspot for `npm create` failures.
|
|
83
|
+
|
|
84
|
+
## Demo script for the agent block (5 minutes, if you demo instead of hands-on)
|
|
85
|
+
|
|
86
|
+
1. On the prepared agent app: `npm run dev`, open the page, click the panel button.
|
|
87
|
+
2. Ask: "Add a route GET /agent-built to src/routes.ts that returns {\"built\": true}, then run the tests and tell me what happened."
|
|
88
|
+
3. Narrate while it runs: the agent reads `AGENTS.md`, reads `docs/framework`, edits `src/routes.ts`, runs the tests; the terminal prints
|
|
89
|
+
`Change detected … Restarting`; reload `/agent-built` and show `{"built":true}`.
|
|
90
|
+
4. Point at the panel's stop button and the audit line the panel writes under `.acrylblends/audit/`.
|
|
91
|
+
|
|
92
|
+
## Resetting between sessions
|
|
93
|
+
|
|
94
|
+
Everything a person makes lives inside their app folder and `~/acryl-workshop`. To reset a machine:
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
rm -rf ~/acryl-workshop/[a-z]*-app ~/acryl-workshop/registry
|
|
98
|
+
cp -R ~/acryl-workshop/prepared/* ~/acryl-workshop/ # if you keep a prepared/ folder
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Nothing is installed globally except the agent and Bun; neither needs cleaning between sessions.
|
|
102
|
+
|
|
103
|
+
## What is still open
|
|
104
|
+
|
|
105
|
+
- Starter pull (B1), the panel's rows (C1) and the upgrade command in the docs (B2) are with the framework team; when they land, the handout's
|
|
106
|
+
TODO lines become normal steps and this page's "what breaks" list shrinks.
|
|
107
|
+
- The Windows and Linux runs of the attendee handout are being replayed separately; add what differs to the handout's OS column rather than here.
|
package/lib/cordis/db-sqlite.js
CHANGED
|
@@ -15,6 +15,10 @@ import z from "@deepseek-ai/schemastery";
|
|
|
15
15
|
*/
|
|
16
16
|
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
17
17
|
const name = "acryl-db-sqlite";
|
|
18
|
+
/** The project folder of an acrylblends app (`appInstance.root`), when the instance has one: where a relative path in a row's config is read from, since such an app has no Loader base URL. */
|
|
19
|
+
function appRoot(value) {
|
|
20
|
+
return typeof value === "object" && value !== null && "root" in value && typeof value.root === "string" ? value.root : void 0;
|
|
21
|
+
}
|
|
18
22
|
function isAppHome(value) {
|
|
19
23
|
return typeof value === "object" && value !== null && "home" in value && typeof value.home === "string";
|
|
20
24
|
}
|
|
@@ -26,12 +30,16 @@ const Config = z.object({
|
|
|
26
30
|
});
|
|
27
31
|
/** The file name used in the app home when `path` is not configured. */
|
|
28
32
|
const DEFAULT_FILE = "data.sqlite";
|
|
29
|
-
/** A configured path as a file path: absolute as given, relative to the Loader's base URL (the app) otherwise. */
|
|
33
|
+
/** A configured path as a file path: absolute as given, relative to the Loader's base URL (the app) otherwise, or to the app's project folder (`appInstance.root`) where there is no base URL. */
|
|
30
34
|
function resolveFromApp(ctx, path, option) {
|
|
31
35
|
if (path.startsWith("file:")) return fileURLToPath(path);
|
|
32
36
|
if (isAbsolute(path)) return path;
|
|
33
37
|
const base = ctx.baseUrl;
|
|
34
|
-
if (base === void 0 || !base.startsWith("file:"))
|
|
38
|
+
if (base === void 0 || !base.startsWith("file:")) {
|
|
39
|
+
const root = appRoot(ctx.get("appInstance"));
|
|
40
|
+
if (root !== void 0) return resolve(root, path);
|
|
41
|
+
throw new Error(`acryl-db-sqlite: ${option} ${JSON.stringify(path)} is relative, but this app has no file base URL to resolve it from; give an absolute path`);
|
|
42
|
+
}
|
|
35
43
|
return resolve(base.endsWith("/") ? fileURLToPath(base) : dirname(fileURLToPath(base)), path);
|
|
36
44
|
}
|
|
37
45
|
async function apply(ctx, rawConfig = {}) {
|
package/lib/cordis/kv-json.js
CHANGED
|
@@ -19,12 +19,20 @@ function isAppHome(value) {
|
|
|
19
19
|
const Config = z.object({ path: z.string().description("The JSON file, absolute or relative to the app. Defaults to kv.json in the ACRYL app home; required where there is none.") });
|
|
20
20
|
/** The file name used in the app home when `path` is not configured. */
|
|
21
21
|
const DEFAULT_FILE = "kv.json";
|
|
22
|
-
/**
|
|
22
|
+
/** The project folder of an acrylblends app (`appInstance.root`), when the instance has one: where a relative path in a row's config is read from, since such an app has no Loader base URL. */
|
|
23
|
+
function appRoot(value) {
|
|
24
|
+
return typeof value === "object" && value !== null && "root" in value && typeof value.root === "string" ? value.root : void 0;
|
|
25
|
+
}
|
|
26
|
+
/** A configured path as a file path: absolute as given, relative to the Loader's base URL (the app) otherwise, or to the app's project folder (`appInstance.root`) where there is no base URL. */
|
|
23
27
|
function resolveFromApp(ctx, path) {
|
|
24
28
|
if (path.startsWith("file:")) return fileURLToPath(path);
|
|
25
29
|
if (isAbsolute(path)) return path;
|
|
26
30
|
const base = ctx.baseUrl;
|
|
27
|
-
if (base === void 0 || !base.startsWith("file:"))
|
|
31
|
+
if (base === void 0 || !base.startsWith("file:")) {
|
|
32
|
+
const root = appRoot(ctx.get("appInstance"));
|
|
33
|
+
if (root !== void 0) return resolve(root, path);
|
|
34
|
+
throw new Error(`acryl-kv-json: path ${JSON.stringify(path)} is relative, but this app has no file base URL to resolve it from; give an absolute path`);
|
|
35
|
+
}
|
|
28
36
|
return resolve(base.endsWith("/") ? fileURLToPath(base) : dirname(fileURLToPath(base)), path);
|
|
29
37
|
}
|
|
30
38
|
async function apply(ctx, rawConfig = {}) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as Request, i as createAdaptorServer, l as RequestError, n as UpgradeEmitter, r as UpgradeHub, t as testWebSocket } from "../test-websocket-
|
|
1
|
+
import { c as Request, i as createAdaptorServer, l as RequestError, n as UpgradeEmitter, r as UpgradeHub, t as testWebSocket } from "../test-websocket-D8WWqE5G.js";
|
|
2
2
|
import { t as serveStatic } from "../serve-static-3uo_9rjF.js";
|
|
3
3
|
import { Service } from "@deepseek-ai/cordis";
|
|
4
4
|
import z from "@deepseek-ai/schemastery";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as UpgradeEmitter, r as UpgradeHub } from "../test-websocket-
|
|
1
|
+
import { n as UpgradeEmitter, r as UpgradeHub } from "../test-websocket-D8WWqE5G.js";
|
|
2
2
|
import "./node-server.js";
|
|
3
3
|
import { Readable } from "node:stream";
|
|
4
4
|
import { Service } from "@deepseek-ai/cordis";
|
package/lib/node-server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as serve, i as createAdaptorServer, l as RequestError, n as UpgradeEmitter, o as upgradeWebSocket, r as UpgradeHub, s as getRequestListener, t as testWebSocket } from "./test-websocket-
|
|
1
|
+
import { a as serve, i as createAdaptorServer, l as RequestError, n as UpgradeEmitter, o as upgradeWebSocket, r as UpgradeHub, s as getRequestListener, t as testWebSocket } from "./test-websocket-D8WWqE5G.js";
|
|
2
2
|
import { t as serveStatic } from "./serve-static-3uo_9rjF.js";
|
|
3
3
|
import { getConnInfo } from "./node-server/conninfo.js";
|
|
4
4
|
import { n as clientModules, t as buildClientModules } from "./client-modules-CLvWOjbd.js";
|
|
@@ -226,8 +226,7 @@ const readBodyBufferedBeforeDisconnect = (incoming, chunks) => {
|
|
|
226
226
|
else if (incomingWithRecovery[bodyBufferedLengthBeforeDisconnectKey] !== void 0 && incoming.readableLength !== incomingWithRecovery[bodyBufferedLengthBeforeDisconnectKey]) result = newBodyUnusableError();
|
|
227
227
|
else {
|
|
228
228
|
const bodyChunks = chunks ?? [];
|
|
229
|
-
|
|
230
|
-
if (chunk !== null) bodyChunks.push(toBufferChunk(chunk, incoming.readableEncoding));
|
|
229
|
+
for (let chunk = incoming.read(); chunk !== null; chunk = incoming.read()) bodyChunks.push(toBufferChunk(chunk, incoming.readableEncoding));
|
|
231
230
|
const buffer = bodyChunks.length === 1 ? bodyChunks[0] : Buffer.concat(bodyChunks);
|
|
232
231
|
result = buffer;
|
|
233
232
|
const contentLength = incoming.headers["content-length"];
|
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"@deepseek-ai/cordis": "4.0.4",
|
|
9
9
|
"@deepseek-ai/schemastery": "3.18.4"
|
|
10
10
|
},
|
|
11
|
-
"mount": "mount it after instance: root.plugin(dbSqlite, { migrations: 'migrations' })
|
|
11
|
+
"mount": "mount it after instance, either as a row in blend.yaml (`- id: db` / `name: acryl-db-sqlite` / `config: { migrations: migrations }`, registered in src/app.ts as 'acryl-db-sqlite') or as root.plugin(dbSqlite, { migrations: 'migrations' }); a relative path is read from the app's folder, and the migrations/ folder (001-name.sql, ...) sits next to src/. Then a plugin that declares inject: ['db'] calls await ctx.db.all('SELECT ...', value)",
|
|
12
12
|
"omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
|
|
13
13
|
"imports": {
|
|
14
14
|
"#acryl-db-sqlite": "acryl-db-sqlite/src/index.ts"
|
|
@@ -30,6 +30,11 @@ interface AppHome {
|
|
|
30
30
|
readonly home: string
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** The project folder of an acrylblends app (`appInstance.root`), when the instance has one: where a relative path in a row's config is read from, since such an app has no Loader base URL. */
|
|
34
|
+
function appRoot(value: unknown): string | undefined {
|
|
35
|
+
return typeof value === 'object' && value !== null && 'root' in value && typeof value.root === 'string' ? value.root : undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
33
38
|
function isAppHome(value: unknown): value is AppHome {
|
|
34
39
|
return typeof value === 'object' && value !== null && 'home' in value && typeof value.home === 'string'
|
|
35
40
|
}
|
|
@@ -51,12 +56,14 @@ export const Config: z<Config> = z.object({
|
|
|
51
56
|
/** The file name used in the app home when `path` is not configured. */
|
|
52
57
|
export const DEFAULT_FILE = 'data.sqlite'
|
|
53
58
|
|
|
54
|
-
/** A configured path as a file path: absolute as given, relative to the Loader's base URL (the app) otherwise. */
|
|
59
|
+
/** A configured path as a file path: absolute as given, relative to the Loader's base URL (the app) otherwise, or to the app's project folder (`appInstance.root`) where there is no base URL. */
|
|
55
60
|
function resolveFromApp(ctx: Context, path: string, option: string): string {
|
|
56
61
|
if (path.startsWith('file:')) return fileURLToPath(path)
|
|
57
62
|
if (isAbsolute(path)) return path
|
|
58
63
|
const base = ctx.baseUrl
|
|
59
64
|
if (base === undefined || !base.startsWith('file:')) {
|
|
65
|
+
const root = appRoot(ctx.get('appInstance'))
|
|
66
|
+
if (root !== undefined) return resolve(root, path)
|
|
60
67
|
throw new Error(`acryl-db-sqlite: ${option} ${JSON.stringify(path)} is relative, but this app has no file base URL to resolve it from; give an absolute path`)
|
|
61
68
|
}
|
|
62
69
|
const appDir = base.endsWith('/') ? fileURLToPath(base) : dirname(fileURLToPath(base))
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"@deepseek-ai/cordis": "4.0.4",
|
|
9
9
|
"@deepseek-ai/schemastery": "3.18.4"
|
|
10
10
|
},
|
|
11
|
-
"mount": "mount it after instance: root.plugin(kvJson, {}) (the file is kv.json in the app home, or set path); then a plugin that declares inject: ['kv'] calls await ctx.kv.set('theme', 'dark') and ctx.kv.update('visits', (n) => (n ?? 0) + 1)",
|
|
11
|
+
"mount": "mount it after instance, as a row in blend.yaml (`- id: kv` / `name: acryl-kv-json`, registered in src/app.ts as 'acryl-kv-json') or as root.plugin(kvJson, {}) (the file is kv.json in the app home, or set path; a relative path is read from the app's folder); then a plugin that declares inject: ['kv'] calls await ctx.kv.set('theme', 'dark') and ctx.kv.update('visits', (n) => (n ?? 0) + 1)",
|
|
12
12
|
"omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
|
|
13
13
|
"imports": {
|
|
14
14
|
"#acryl-kv-json": "acryl-kv-json/src/index.ts"
|
|
@@ -41,12 +41,19 @@ export const Config: z<Config> = z.object({
|
|
|
41
41
|
/** The file name used in the app home when `path` is not configured. */
|
|
42
42
|
export const DEFAULT_FILE = 'kv.json'
|
|
43
43
|
|
|
44
|
-
/**
|
|
44
|
+
/** The project folder of an acrylblends app (`appInstance.root`), when the instance has one: where a relative path in a row's config is read from, since such an app has no Loader base URL. */
|
|
45
|
+
function appRoot(value: unknown): string | undefined {
|
|
46
|
+
return typeof value === 'object' && value !== null && 'root' in value && typeof value.root === 'string' ? value.root : undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A configured path as a file path: absolute as given, relative to the Loader's base URL (the app) otherwise, or to the app's project folder (`appInstance.root`) where there is no base URL. */
|
|
45
50
|
function resolveFromApp(ctx: Context, path: string): string {
|
|
46
51
|
if (path.startsWith('file:')) return fileURLToPath(path)
|
|
47
52
|
if (isAbsolute(path)) return path
|
|
48
53
|
const base = ctx.baseUrl
|
|
49
54
|
if (base === undefined || !base.startsWith('file:')) {
|
|
55
|
+
const root = appRoot(ctx.get('appInstance'))
|
|
56
|
+
if (root !== undefined) return resolve(root, path)
|
|
50
57
|
throw new Error(`acryl-kv-json: path ${JSON.stringify(path)} is relative, but this app has no file base URL to resolve it from; give an absolute path`)
|
|
51
58
|
}
|
|
52
59
|
const appDir = base.endsWith('/') ? fileURLToPath(base) : dirname(fileURLToPath(base))
|
|
@@ -135,8 +135,9 @@ const readBodyBufferedBeforeDisconnect = (
|
|
|
135
135
|
result = newBodyUnusableError()
|
|
136
136
|
} else {
|
|
137
137
|
const bodyChunks = chunks ?? []
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
// Drain what is still buffered. On a destroyed stream one read() returns all of it on some Node versions and only the first chunk on others (Node 26), so read until
|
|
139
|
+
// the stream says there is no more: that is what Readable#read() promises, and it needs nothing of Node's internals.
|
|
140
|
+
for (let chunk = incoming.read() as Buffer | string | null; chunk !== null; chunk = incoming.read()) {
|
|
140
141
|
bodyChunks.push(toBufferChunk(chunk, incoming.readableEncoding))
|
|
141
142
|
}
|
|
142
143
|
const buffer = bodyChunks.length === 1 ? bodyChunks[0] : Buffer.concat(bodyChunks)
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/** The panel's own drivers, all `chat`: the app's agent, and the coding agents (`claude`, `codex`, `pi`) started headless in the app folder. */
|
|
2
2
|
import { spawn, spawnSync } from 'node:child_process'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
3
6
|
import type { Context } from '@deepseek-ai/cordis'
|
|
4
7
|
import type { ChatDriver, ChatEvent } from './drivers.ts'
|
|
5
8
|
|
|
@@ -62,11 +65,30 @@ export function onPath(program: string): boolean {
|
|
|
62
65
|
return found.status === 0
|
|
63
66
|
}
|
|
64
67
|
|
|
65
|
-
/**
|
|
68
|
+
/** Whether pi can reach DeepSeek: the key in the environment, or the one pi stored for itself when its owner logged in (`~/.pi/agent/auth.json`, a `deepseek` entry). */
|
|
69
|
+
export function piHasDeepseekKey(
|
|
70
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
71
|
+
home: string = homedir(),
|
|
72
|
+
): boolean {
|
|
73
|
+
if (env.DEEPSEEK_API_KEY) return true
|
|
74
|
+
try {
|
|
75
|
+
const stored = JSON.parse(
|
|
76
|
+
readFileSync(
|
|
77
|
+
join(env.PI_CODING_AGENT_DIR ?? join(home, '.pi', 'agent'), 'auth.json'),
|
|
78
|
+
'utf8',
|
|
79
|
+
),
|
|
80
|
+
) as Record<string, { key?: unknown } | undefined>
|
|
81
|
+
return typeof stored.deepseek?.key === 'string' && stored.deepseek.key !== ''
|
|
82
|
+
} catch {
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The headless programs this machine can offer: installed (an executable on the PATH; a wrapper function around it is fine) and, for pi, able to reach its model. */
|
|
66
88
|
export function availableHeadless(wanted?: readonly HeadlessName[]): HeadlessName[] {
|
|
67
89
|
const found: HeadlessName[] = []
|
|
68
90
|
for (const driver of wanted ?? (['claude', 'codex', 'pi'] as const)) {
|
|
69
|
-
if (driver === 'pi' && !
|
|
91
|
+
if (driver === 'pi' && !piHasDeepseekKey()) continue
|
|
70
92
|
if (onPath(driver)) found.push(driver)
|
|
71
93
|
}
|
|
72
94
|
return found
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npm run desktop`: starts Electron on `main.ts` next to this file. It exists for one case: on Linux where the kernel restricts unprivileged user namespaces
|
|
3
|
+
* (Ubuntu 23.10 and later: `kernel.apparmor_restrict_unprivileged_userns=1`), Chromium's sandbox cannot start for an Electron that npm unpacked into node_modules, and Electron
|
|
4
|
+
* aborts before any of our code runs. There, and only there, it starts with `--no-sandbox` and says so; everywhere else Electron starts exactly as it does with the sandbox on.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { createRequire } from 'node:module'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
|
|
12
|
+
/** The sysctl that makes the sandbox fail for an Electron without an AppArmor profile; its value, or `undefined` where it does not exist (every system but a recent Ubuntu). */
|
|
13
|
+
export const RESTRICT_USERNS = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns'
|
|
14
|
+
|
|
15
|
+
/** Whether the sandbox cannot start here: Linux, with the restriction on. `read` returns a file's text, or throws when there is none. */
|
|
16
|
+
export function sandboxBlocked(
|
|
17
|
+
platform: string,
|
|
18
|
+
read: (path: string) => string = (path) => readFileSync(path, 'utf8'),
|
|
19
|
+
): boolean {
|
|
20
|
+
if (platform !== 'linux') return false
|
|
21
|
+
try {
|
|
22
|
+
return read(RESTRICT_USERNS).trim() === '1'
|
|
23
|
+
} catch {
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const SANDBOX_NOTICE =
|
|
29
|
+
"desktop: this Linux restricts unprivileged user namespaces (kernel.apparmor_restrict_unprivileged_userns=1), so Electron's sandbox cannot start; starting with --no-sandbox. " +
|
|
30
|
+
'To keep the sandbox, give Electron an AppArmor profile that allows `userns` (the way the VS Code and Chrome packages do): see docs/framework/getting-started/desktop.md'
|
|
31
|
+
|
|
32
|
+
/** The arguments for Electron: the entry file, with `--no-sandbox` first when the sandbox is blocked. */
|
|
33
|
+
export function electronArgs(entry: string, blocked: boolean): string[] {
|
|
34
|
+
return blocked ? ['--no-sandbox', entry] : [entry]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
38
|
+
const electron = createRequire(import.meta.url)('electron') as string
|
|
39
|
+
// ACRYLBLENDS_DESKTOP_SANDBOX=1: the owner has given Electron an AppArmor profile and wants the sandbox.
|
|
40
|
+
const blocked =
|
|
41
|
+
process.env.ACRYLBLENDS_DESKTOP_SANDBOX !== '1' && sandboxBlocked(process.platform)
|
|
42
|
+
if (blocked) console.warn(SANDBOX_NOTICE)
|
|
43
|
+
const child = spawn(electron, electronArgs(join(import.meta.dirname, 'main.ts'), blocked), {
|
|
44
|
+
stdio: 'inherit',
|
|
45
|
+
})
|
|
46
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => child.kill(signal))
|
|
47
|
+
child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
|
|
48
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"electron": "44.9.3"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
|
-
"desktop": "
|
|
12
|
+
"desktop": "node plugins/desktop/launch.ts"
|
|
13
13
|
},
|
|
14
14
|
"omit": ["**/*.test.*"],
|
|
15
15
|
"mount": "nothing to mount: run `npm install`, then `npm run desktop` (the window opens on the app's own server; every electron call is in plugins/desktop)"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npm run desktop`: starts Electron on `main.ts` next to this file. It exists for one case: on Linux where the kernel restricts unprivileged user namespaces
|
|
3
|
+
* (Ubuntu 23.10 and later: `kernel.apparmor_restrict_unprivileged_userns=1`), Chromium's sandbox cannot start for an Electron that npm unpacked into node_modules, and Electron
|
|
4
|
+
* aborts before any of our code runs. There, and only there, it starts with `--no-sandbox` and says so; everywhere else Electron starts exactly as it does with the sandbox on.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { createRequire } from 'node:module'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
|
|
12
|
+
/** The sysctl that makes the sandbox fail for an Electron without an AppArmor profile; its value, or `undefined` where it does not exist (every system but a recent Ubuntu). */
|
|
13
|
+
export const RESTRICT_USERNS = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns'
|
|
14
|
+
|
|
15
|
+
/** Whether the sandbox cannot start here: Linux, with the restriction on. `read` returns a file's text, or throws when there is none. */
|
|
16
|
+
export function sandboxBlocked(
|
|
17
|
+
platform: string,
|
|
18
|
+
read: (path: string) => string = (path) => readFileSync(path, 'utf8'),
|
|
19
|
+
): boolean {
|
|
20
|
+
if (platform !== 'linux') return false
|
|
21
|
+
try {
|
|
22
|
+
return read(RESTRICT_USERNS).trim() === '1'
|
|
23
|
+
} catch {
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const SANDBOX_NOTICE =
|
|
29
|
+
"desktop: this Linux restricts unprivileged user namespaces (kernel.apparmor_restrict_unprivileged_userns=1), so Electron's sandbox cannot start; starting with --no-sandbox. " +
|
|
30
|
+
'To keep the sandbox, give Electron an AppArmor profile that allows `userns` (the way the VS Code and Chrome packages do): see docs/framework/getting-started/desktop.md'
|
|
31
|
+
|
|
32
|
+
/** The arguments for Electron: the entry file, with `--no-sandbox` first when the sandbox is blocked. */
|
|
33
|
+
export function electronArgs(entry: string, blocked: boolean): string[] {
|
|
34
|
+
return blocked ? ['--no-sandbox', entry] : [entry]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
38
|
+
const electron = createRequire(import.meta.url)('electron') as string
|
|
39
|
+
// ACRYLBLENDS_DESKTOP_SANDBOX=1: the owner has given Electron an AppArmor profile and wants the sandbox.
|
|
40
|
+
const blocked =
|
|
41
|
+
process.env.ACRYLBLENDS_DESKTOP_SANDBOX !== '1' && sandboxBlocked(process.platform)
|
|
42
|
+
if (blocked) console.warn(SANDBOX_NOTICE)
|
|
43
|
+
const child = spawn(electron, electronArgs(join(import.meta.dirname, 'main.ts'), blocked), {
|
|
44
|
+
stdio: 'inherit',
|
|
45
|
+
})
|
|
46
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => child.kill(signal))
|
|
47
|
+
child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
|
|
48
|
+
}
|
package/templates/chat/AGENTS.md
CHANGED
|
@@ -13,4 +13,4 @@ A Cordis app: `blend.yaml` lists the plugins that are mounted (`acryl-router`, `
|
|
|
13
13
|
|
|
14
14
|
## Before you ship
|
|
15
15
|
|
|
16
|
-
Read `docs/framework/getting-started/deploy.md`. In short: `npm ci --omit=dev`, `npm run build`, `NODE_ENV=production npm start`; the app listens on `127.0.0.1` (`HOST=0.0.0.0` only in a container); the SQLite file goes outside the code folder (`CHAT_DB=/var/lib/chat/chat.sqlite`) and is backed up; the model key is an environment variable of the service (a root-only environment file for systemd), never a file in the repository. Anyone who can reach the app can spend your key
|
|
16
|
+
Read `docs/framework/getting-started/deploy.md`. In short: `npm ci --omit=dev`, `npm run build`, `NODE_ENV=production npm start`; the app listens on `127.0.0.1` (`HOST=0.0.0.0` only in a container); the SQLite file goes outside the code folder (`CHAT_DB=/var/lib/chat/chat.sqlite`) and is backed up; the model key is an environment variable of the service (a root-only environment file for systemd), never a file in the repository. Anyone who can reach the app can spend your key, so with `NODE_ENV=production` the chat is **closed**: every route but `GET /health` answers 503 (`src/access.ts`). Set `CHAT_ACCESS_TOKEN` (the browser asks for it; any user name, the token as the password), or name the chat in `ACRYL_PRODUCTION_KEEP=chat` to make it public on purpose: that prints a `WARNING` at start and limits one address to `CHAT_RATE_PER_MINUTE` messages a minute (default 20). Prefer your own login in front of a public chat.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { connectionOf, createMiddleware } from '../plugins/acryl-router/src/helpers/index.ts'
|
|
3
|
+
import type { Context } from '../plugins/acryl-router/src/index.ts'
|
|
4
|
+
import { basicAuth } from '../plugins/acryl-router/src/middleware/index.ts'
|
|
5
|
+
|
|
6
|
+
/** Who may use the chat, and what the app prints about it at start. */
|
|
7
|
+
export type Access =
|
|
8
|
+
/** Development, and every test: no gate. */
|
|
9
|
+
| { kind: 'open' }
|
|
10
|
+
/** Production with `ACRYL_PRODUCTION_KEEP=chat`: anyone, with a per-IP rate limit. */
|
|
11
|
+
| { kind: 'public'; perMinute: number; trustProxy: boolean }
|
|
12
|
+
/** Production with `CHAT_ACCESS_TOKEN`: the browser asks for it (any user name, the token as the password). */
|
|
13
|
+
| { kind: 'token'; token: string }
|
|
14
|
+
/** Production with neither: every route but `/health` answers 503. */
|
|
15
|
+
| { kind: 'closed' }
|
|
16
|
+
|
|
17
|
+
export interface Decision {
|
|
18
|
+
readonly access: Access
|
|
19
|
+
/** Lines to print at start (a `WARNING` for a public chat, the reason for a closed one). */
|
|
20
|
+
readonly notices: readonly string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Anyone who can reach the chat spends the model key, so in production it is closed by default. Name the chat in `ACRYL_PRODUCTION_KEEP` to make it public
|
|
25
|
+
* (loudly, with a rate limit), or set `CHAT_ACCESS_TOKEN` to ask for a shared token.
|
|
26
|
+
*/
|
|
27
|
+
export function chatAccess(env: Readonly<Record<string, string | undefined>>): Decision {
|
|
28
|
+
if (env.NODE_ENV !== 'production') return { access: { kind: 'open' }, notices: [] }
|
|
29
|
+
const keep = (env.ACRYL_PRODUCTION_KEEP ?? '')
|
|
30
|
+
.split(',')
|
|
31
|
+
.map((name) => name.trim())
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
if (keep.includes('chat')) {
|
|
34
|
+
const perMinute = Number(env.CHAT_RATE_PER_MINUTE)
|
|
35
|
+
return {
|
|
36
|
+
access: {
|
|
37
|
+
kind: 'public',
|
|
38
|
+
perMinute: Number.isInteger(perMinute) && perMinute > 0 ? perMinute : 20,
|
|
39
|
+
trustProxy: env.TRUST_PROXY === '1',
|
|
40
|
+
},
|
|
41
|
+
notices: [
|
|
42
|
+
'WARNING: the chat is public in production because ACRYL_PRODUCTION_KEEP names "chat": anyone who can reach it spends your model key (each address is limited to a few messages a minute). Put your own login in front of it, or set CHAT_ACCESS_TOKEN instead.',
|
|
43
|
+
],
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const token = env.CHAT_ACCESS_TOKEN?.trim()
|
|
47
|
+
if (token) return { access: { kind: 'token', token }, notices: [] }
|
|
48
|
+
return {
|
|
49
|
+
access: { kind: 'closed' },
|
|
50
|
+
notices: [
|
|
51
|
+
'The chat is closed: with NODE_ENV=production it needs CHAT_ACCESS_TOKEN (the browser asks for it) or ACRYL_PRODUCTION_KEEP=chat to be public. Every route but /health answers 503 until then.',
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const digest = (text: string) => createHash('sha256').update(text).digest()
|
|
57
|
+
|
|
58
|
+
/** The middleware for an access mode, mounted before the routes. `/health` is always open. */
|
|
59
|
+
export function accessGate(access: Access) {
|
|
60
|
+
if (access.kind === 'open') return undefined
|
|
61
|
+
if (access.kind === 'closed')
|
|
62
|
+
return createMiddleware(async (c, next) => {
|
|
63
|
+
if (c.req.path === '/health') return next()
|
|
64
|
+
return c.json(
|
|
65
|
+
{
|
|
66
|
+
error:
|
|
67
|
+
'The chat is closed. Set CHAT_ACCESS_TOKEN, or ACRYL_PRODUCTION_KEEP=chat to make it public.',
|
|
68
|
+
},
|
|
69
|
+
503,
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
if (access.kind === 'token') {
|
|
73
|
+
const expected = digest(access.token)
|
|
74
|
+
const ask = basicAuth({
|
|
75
|
+
realm: 'chat',
|
|
76
|
+
verifyUser: (_user, password) => timingSafeEqual(digest(password), expected),
|
|
77
|
+
})
|
|
78
|
+
return createMiddleware(async (c, next) => (c.req.path === '/health' ? next() : ask(c, next)))
|
|
79
|
+
}
|
|
80
|
+
return rateLimit(access.perMinute, access.trustProxy)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** At most `perMinute` messages a minute from one address (`POST /api/chat`); the table forgets an address a minute after its last message. */
|
|
84
|
+
function rateLimit(perMinute: number, trustProxy: boolean) {
|
|
85
|
+
const seen = new Map<string, number[]>()
|
|
86
|
+
const addressOf = (c: Context) => {
|
|
87
|
+
if (trustProxy) {
|
|
88
|
+
const forwarded = c.req.header('x-forwarded-for')?.split(',').at(-1)?.trim()
|
|
89
|
+
if (forwarded) return forwarded
|
|
90
|
+
}
|
|
91
|
+
return connectionOf(c).remoteAddress ?? 'unknown'
|
|
92
|
+
}
|
|
93
|
+
return createMiddleware(async (c, next) => {
|
|
94
|
+
if (c.req.method !== 'POST' || c.req.path !== '/api/chat') return next()
|
|
95
|
+
const now = Date.now()
|
|
96
|
+
for (const [address, times] of seen)
|
|
97
|
+
if (now - (times.at(-1) ?? 0) > 60_000) seen.delete(address)
|
|
98
|
+
const address = addressOf(c)
|
|
99
|
+
const recent = (seen.get(address) ?? []).filter((time) => now - time < 60_000)
|
|
100
|
+
if (recent.length >= perMinute) {
|
|
101
|
+
seen.set(address, recent)
|
|
102
|
+
const wait = Math.ceil((60_000 - (now - (recent[0] ?? now))) / 1000)
|
|
103
|
+
c.header('retry-after', String(wait))
|
|
104
|
+
return c.json({ error: 'Too many messages. Try again in a minute.' }, 429)
|
|
105
|
+
}
|
|
106
|
+
recent.push(now)
|
|
107
|
+
seen.set(address, recent)
|
|
108
|
+
return next()
|
|
109
|
+
})
|
|
110
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
streamSSE,
|
|
6
6
|
} from '../plugins/acryl-router/src/helpers/index.ts'
|
|
7
7
|
import { Acryl } from '../plugins/acryl-router/src/index.ts'
|
|
8
|
+
import { type Access, accessGate } from './access.ts'
|
|
8
9
|
import { chatStore } from './db.ts'
|
|
9
10
|
import { type Llm, openAiCompatible } from './llm.ts'
|
|
10
11
|
import { chatPage } from './page.tsx'
|
|
@@ -32,9 +33,14 @@ const ChatSchema: StandardSchema<ChatInput> = {
|
|
|
32
33
|
},
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
/** `access` is who may use the chat (default: anyone, as in development and in the tests); see `src/access.ts`. */
|
|
37
|
+
export function createApp(db: Db, llm: Llm = openAiCompatible, access: Access = { kind: 'open' }) {
|
|
36
38
|
const store = chatStore(db)
|
|
37
39
|
const app = new Acryl()
|
|
40
|
+
const gate = accessGate(access)
|
|
41
|
+
if (gate) app.use(gate)
|
|
42
|
+
|
|
43
|
+
app.get('/health', (c) => c.json({ ok: true }))
|
|
38
44
|
|
|
39
45
|
app.get('/', async (c) => {
|
|
40
46
|
const id = Number(c.req.query('c'))
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis'
|
|
2
2
|
import type {} from '../plugins/acryl-db/src/cordis.ts'
|
|
3
|
+
import { chatAccess } from './access.ts'
|
|
3
4
|
import { createApp } from './chat-app.ts'
|
|
4
5
|
import { openAiCompatible } from './llm.ts'
|
|
5
6
|
import { createClient } from './runtime.ts'
|
|
@@ -9,7 +10,10 @@ export const routes = {
|
|
|
9
10
|
name: 'routes',
|
|
10
11
|
inject: ['router', 'db'],
|
|
11
12
|
apply(ctx: Context) {
|
|
12
|
-
|
|
13
|
+
// In production the chat is closed unless it has a token or is made public on purpose: anyone who reaches it spends the model key.
|
|
14
|
+
const { access, notices } = chatAccess(process.env)
|
|
15
|
+
for (const line of notices) console.warn(line)
|
|
16
|
+
ctx.router.route('/', createApp(ctx.db, openAiCompatible, access))
|
|
13
17
|
ctx.router.route('/client', createClient())
|
|
14
18
|
},
|
|
15
19
|
}
|