acrylblends 0.7.7 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/dist/index.d.ts +14 -6
  2. package/dist/index.js +130 -31
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/README.md +3 -3
  5. package/docs/framework/UPSTREAM.md +1 -1
  6. package/docs/framework/agents/index.md +18 -3
  7. package/docs/framework/getting-started/basic.md +1 -2
  8. package/docs/framework/getting-started/desktop.md +3 -3
  9. package/docs/framework/guides/bring-your-own-tool.md +216 -0
  10. package/docs/framework/guides/client-modules.md +4 -3
  11. package/docs/framework/guides/create-acrylblends.md +5 -4
  12. package/docs/framework/guides/data.md +7 -6
  13. package/docs/framework/helpers/streaming.md +12 -27
  14. package/docs/framework/upstream.json +3 -3
  15. package/lib/browser/read-sse.js +84 -0
  16. package/lib/db/sqlite.d.ts +1 -1
  17. package/lib/db.d.ts +1 -84
  18. package/lib/index-BLTYsyc1.d.ts +93 -0
  19. package/lib/index-D4FAsBZu.d.ts +85 -0
  20. package/lib/kv/db.d.ts +2 -2
  21. package/lib/kv/json.d.ts +1 -1
  22. package/lib/kv/json.js +87 -24
  23. package/lib/kv.d.ts +1 -92
  24. package/lib/node-server.d.ts +3 -3
  25. package/lib/node-server.js +14 -8
  26. package/lib/read-sse.d.ts +33 -0
  27. package/lib/read-sse.js +84 -0
  28. package/package.json +5 -1
  29. package/plugins/acryl-db/plugin.json +7 -2
  30. package/plugins/acryl-db/src/conformance.ts +224 -0
  31. package/plugins/acryl-db/upstream.json +2 -2
  32. package/plugins/acryl-db-sqlite/plugin.json +4 -1
  33. package/plugins/acryl-db-sqlite/upstream.json +1 -1
  34. package/plugins/acryl-door/LICENSE +47 -0
  35. package/plugins/acryl-door/plugin.json +14 -0
  36. package/plugins/acryl-door/src/cli/acryl.ts +207 -0
  37. package/plugins/acryl-door/src/host/api.ts +9 -0
  38. package/plugins/acryl-door/src/host/audit.ts +64 -0
  39. package/plugins/acryl-door/src/host/channel.ts +142 -0
  40. package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
  41. package/plugins/acryl-door/src/host/door-file.ts +94 -0
  42. package/plugins/acryl-door/src/host/door.ts +195 -0
  43. package/plugins/acryl-door/src/host/http.ts +80 -0
  44. package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
  45. package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
  46. package/plugins/acryl-door/src/host/page-routes.ts +65 -0
  47. package/plugins/acryl-door/src/host/shim.ts +65 -0
  48. package/plugins/acryl-door/src/host/verbs.ts +109 -0
  49. package/plugins/acryl-door/src/index.ts +133 -0
  50. package/plugins/acryl-door/src/page/api.ts +9 -0
  51. package/plugins/acryl-door/src/page/channel.ts +135 -0
  52. package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
  53. package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
  54. package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
  55. package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
  56. package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
  57. package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
  58. package/plugins/acryl-door/src/page/index.ts +40 -0
  59. package/plugins/acryl-door/src/page/indicator.ts +72 -0
  60. package/plugins/acryl-door/src/page/main.ts +7 -0
  61. package/plugins/acryl-door/src/page/protocol.ts +213 -0
  62. package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
  63. package/plugins/acryl-door/src/page/user-input.ts +24 -0
  64. package/plugins/acryl-door/upstream.json +28 -0
  65. package/plugins/acryl-inspect/upstream.json +1 -1
  66. package/plugins/acryl-kv/plugin.json +7 -2
  67. package/plugins/acryl-kv/src/conformance.ts +226 -0
  68. package/plugins/acryl-kv/upstream.json +1 -1
  69. package/plugins/acryl-kv-db/plugin.json +4 -1
  70. package/plugins/acryl-kv-db/upstream.json +1 -1
  71. package/plugins/acryl-kv-json/plugin.json +4 -1
  72. package/plugins/acryl-kv-json/src/open.ts +86 -22
  73. package/plugins/acryl-kv-json/upstream.json +2 -2
  74. package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
  75. package/plugins/acryl-node-server/src/client-modules.ts +10 -9
  76. package/plugins/acryl-node-server/upstream.json +2 -2
  77. package/plugins/acryl-router/src/browser/read-sse.ts +3 -0
  78. package/plugins/acryl-router/src/helpers/read-sse.ts +111 -0
  79. package/plugins/acryl-router/src/service.ts +3 -0
  80. package/plugins/acryl-router/upstream.json +2 -2
  81. package/plugins/acryl-router-http/upstream.json +1 -1
  82. package/plugins/acryl-state/upstream.json +1 -1
  83. package/plugins/agent-panel/client/main.ts +159 -0
  84. package/plugins/agent-panel/index.ts +232 -0
  85. package/plugins/agent-panel/plugin.json +21 -0
  86. package/plugins/builder/index.ts +2 -2
  87. package/plugins/builder/plugin.json +1 -1
  88. package/plugins/desktop/main.ts +3 -3
  89. package/plugins/desktop/plugin.json +2 -2
  90. package/plugins/desktop/upstream.json +1 -1
  91. package/plugins/desktop-web/main.ts +2 -2
  92. package/plugins/desktop-web/plugin.json +2 -2
  93. package/plugins/desktop-web/upstream.json +1 -1
  94. package/plugins/extensions/index.ts +19 -1
  95. package/plugins/extensions/plugin.json +1 -1
  96. package/plugins/extensions/resolve.ts +17 -3
  97. package/plugins/manifest/lock.ts +7 -3
  98. package/plugins/manifest/upgrade-cli.ts +1 -1
  99. package/plugins/save/cli.ts +1 -1
  100. package/plugins/save/plugin.json +1 -1
  101. package/plugins/tsconfig.json +6 -0
  102. package/shared/AGENTS.core.md +10 -10
  103. package/shared/DEFAULTS.md +45 -0
  104. package/shared/SKILL.md +14 -0
  105. package/templates/agent/AGENTS.md +1 -1
  106. package/templates/agent/README.md +6 -6
  107. package/templates/agent/blend.yaml +3 -0
  108. package/templates/agent/gitignore +1 -0
  109. package/templates/agent/package.json +8 -8
  110. package/templates/agent/src/app.ts +46 -17
  111. package/templates/agent/src/main.ts +2 -2
  112. package/templates/agent/src/routes.ts +19 -6
  113. package/templates/agent/template.json +2 -1
  114. package/templates/agent/test/agent.test.ts +11 -5
  115. package/templates/agent/test/app.test.ts +30 -9
  116. package/templates/agent/test/builder.test.ts +35 -24
  117. package/templates/agent/test/model.test.ts +25 -20
  118. package/templates/agent/tsconfig.json +48 -50
  119. package/templates/blank/AGENTS.md +1 -1
  120. package/templates/blank/README.md +2 -2
  121. package/templates/blank/gitignore +1 -0
  122. package/templates/blank/package.json +8 -8
  123. package/templates/blank/src/about.tsx +2 -2
  124. package/templates/blank/src/app.ts +29 -9
  125. package/templates/blank/src/main.ts +1 -1
  126. package/templates/blank/src/routes.ts +1 -1
  127. package/templates/blank/test/app.test.ts +29 -10
  128. package/templates/blank/tsconfig.json +1 -1
  129. package/templates/chat/AGENTS.md +16 -0
  130. package/templates/chat/blend.yaml +29 -0
  131. package/templates/chat/gitignore +6 -0
  132. package/templates/chat/migrations/001-chat.sql +12 -0
  133. package/templates/chat/package.json +33 -0
  134. package/templates/chat/src/app.ts +106 -0
  135. package/templates/chat/src/chat-app.ts +101 -0
  136. package/templates/chat/src/client/chat.tsx +115 -0
  137. package/templates/chat/src/client/tsconfig.json +26 -0
  138. package/templates/chat/src/db.ts +43 -0
  139. package/templates/chat/src/door.ts +7 -0
  140. package/templates/chat/src/llm.ts +42 -0
  141. package/templates/chat/src/main.ts +28 -0
  142. package/templates/chat/src/page.tsx +40 -0
  143. package/templates/chat/src/routes.ts +15 -0
  144. package/templates/chat/src/runtime.ts +17 -0
  145. package/templates/chat/template.json +11 -0
  146. package/templates/chat/test/chat.test.ts +198 -0
  147. package/templates/chat/test/server.test.ts +78 -0
  148. package/templates/chat/tsconfig.json +20 -0
  149. package/templates/nodejs/AGENTS.md +9 -0
  150. package/templates/nodejs/blend.yaml +26 -0
  151. package/templates/nodejs/gitignore +3 -0
  152. package/templates/nodejs/package.json +30 -0
  153. package/templates/nodejs/src/app.ts +92 -0
  154. package/templates/nodejs/src/client/counter.tsx +12 -0
  155. package/templates/nodejs/src/client/tsconfig.json +26 -0
  156. package/templates/nodejs/src/door.ts +7 -0
  157. package/templates/nodejs/src/main.ts +25 -0
  158. package/templates/nodejs/src/page.tsx +29 -0
  159. package/templates/nodejs/src/routes.ts +40 -0
  160. package/templates/nodejs/template.json +3 -0
  161. package/templates/nodejs/test/app.test.ts +78 -0
  162. package/templates/nodejs/test/server.test.ts +83 -0
  163. package/templates/nodejs/tsconfig.json +20 -0
  164. package/templates/notes/AGENTS.md +26 -0
  165. package/templates/notes/blend.yaml +29 -0
  166. package/templates/notes/gitignore +6 -0
  167. package/templates/notes/migrations/001-notes.sql +18 -0
  168. package/templates/notes/package.json +33 -0
  169. package/templates/notes/src/app.ts +106 -0
  170. package/templates/notes/src/auth.ts +57 -0
  171. package/templates/notes/src/client/counter.tsx +21 -0
  172. package/templates/notes/src/client/tsconfig.json +26 -0
  173. package/templates/notes/src/db.ts +50 -0
  174. package/templates/notes/src/door.ts +7 -0
  175. package/templates/notes/src/main.ts +28 -0
  176. package/templates/notes/src/notes-app.ts +205 -0
  177. package/templates/notes/src/routes.ts +14 -0
  178. package/templates/notes/src/runtime.ts +17 -0
  179. package/templates/notes/src/schema.ts +38 -0
  180. package/templates/notes/src/views.tsx +136 -0
  181. package/templates/notes/template.json +11 -0
  182. package/templates/notes/test/notes.test.ts +280 -0
  183. package/templates/notes/test/server.test.ts +78 -0
  184. package/templates/notes/tsconfig.json +20 -0
  185. package/templates/todo/README.md +1 -1
  186. package/templates/todo/gitignore +1 -0
  187. package/templates/todo/package.json +8 -8
  188. package/templates/todo/src/app.ts +32 -12
  189. package/templates/todo/src/main.ts +1 -1
  190. package/templates/todo/src/todo.ts +4 -4
  191. package/templates/todo/test/todo.test.ts +48 -23
  192. package/templates/todo/tsconfig.json +1 -1
  193. package/templates/todo/upstream.json +2 -2
  194. package/lib/database-D_UN6We8.d.ts +0 -44
  195. package/lib/kv-DWDm0Lja.d.ts +0 -39
@@ -1,19 +1,26 @@
1
1
  import assert from 'node:assert/strict'
2
+ import { mkdirSync } from 'node:fs'
2
3
  import { mkdtemp, rm } from 'node:fs/promises'
3
- import { tmpdir } from 'node:os'
4
+ import { createServer } from 'node:net'
4
5
  import { join } from 'node:path'
5
6
  import { test } from 'node:test'
6
7
  import { fileURLToPath } from 'node:url'
8
+ import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
7
9
  import { type App, createApp } from '../src/app.ts'
8
- import { pinnedInstance, withPort } from '../src/plugins/instance/index.ts'
9
10
  import { APP_NAME } from '../src/routes.ts'
10
11
 
12
+ /** Throwaway homes live inside the project (`.acrylblends/` is ignored by git): a sandboxed agent may not write to the system temp folder. */
13
+ const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
14
+ mkdirSync(scratch, { recursive: true })
15
+
11
16
  const projectRoot = fileURLToPath(new URL('..', import.meta.url))
12
17
 
13
18
  /** Boot the app in a throwaway home on any free port, run the test, always stop it and remove the home. */
14
19
  async function withApp(run: (app: App) => Promise<void>) {
15
- const home = await mkdtemp(join(tmpdir(), 'app-test-'))
16
- const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0))
20
+ const home = await mkdtemp(join(scratch, 'app-test-'))
21
+ const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0), {
22
+ listen: false,
23
+ })
17
24
  try {
18
25
  await run(app)
19
26
  } finally {
@@ -24,14 +31,14 @@ async function withApp(run: (app: App) => Promise<void>) {
24
31
 
25
32
  test('serves the index route', () =>
26
33
  withApp(async (app) => {
27
- const response = await fetch(`${app.url}/`)
34
+ const response = await app.root.router.request(`/`)
28
35
  assert.equal(response.status, 200)
29
36
  assert.deepEqual(await response.json(), { app: APP_NAME, ok: true })
30
37
  }))
31
38
 
32
39
  test('renders a .tsx page, and an error in it names the .tsx line', () =>
33
40
  withApp(async (app) => {
34
- const response = await fetch(`${app.url}/about`)
41
+ const response = await app.root.router.request(`/about`)
35
42
  assert.equal(response.status, 200)
36
43
  assert.match(await response.text(), /<main><h1>About<\/h1>/)
37
44
  const { failingPage } = await import('../src/about.tsx')
@@ -46,14 +53,26 @@ test('renders a .tsx page, and an error in it names the .tsx line', () =>
46
53
 
47
54
  test('answers health checks and 404 for unknown paths', () =>
48
55
  withApp(async (app) => {
49
- assert.equal((await fetch(`${app.url}/health`)).status, 200)
50
- const missing = await fetch(`${app.url}/nope`)
56
+ assert.equal((await app.root.router.request(`/health`)).status, 200)
57
+ const missing = await app.root.router.request(`/nope`)
51
58
  assert.equal(missing.status, 404)
52
59
  await missing.body?.cancel()
53
60
  }))
54
61
 
55
- test('stops listening when stopped', async () => {
56
- const home = await mkdtemp(join(tmpdir(), 'app-test-'))
62
+ /** A sandbox may forbid listening on a port (Codex's workspace-write does); the one test that starts the real server is skipped there, and says so. */
63
+ const canListen = await new Promise<boolean>((done) => {
64
+ const probe = createServer()
65
+ probe.once('error', () => done(false))
66
+ probe.listen(0, '127.0.0.1', () => probe.close(() => done(true)))
67
+ })
68
+ const skip = canListen ? false : 'this environment does not allow listening on a port'
69
+ if (!canListen)
70
+ console.log(
71
+ `skipped: the test that starts the real server (${skip}); every other test ran in process`,
72
+ )
73
+
74
+ test('stops listening when stopped', { skip }, async () => {
75
+ const home = await mkdtemp(join(scratch, 'app-test-'))
57
76
  const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0))
58
77
  await app.stop()
59
78
  await assert.rejects(fetch(`${app.url}/health`))
@@ -15,5 +15,5 @@
15
15
  "jsx": "react-jsx",
16
16
  "jsxImportSource": "#acryl-jsx"
17
17
  },
18
- "include": ["src", "test"]
18
+ "include": ["src", "test", "plugins"]
19
19
  }
@@ -0,0 +1,16 @@
1
+
2
+ ## This app (chat): a streamed answer, history in a database
3
+
4
+ A Cordis app: `blend.yaml` lists the plugins that are mounted (`acryl-router`, `acryl-db-sqlite`, `routes`, `acryl-node-server`), `src/app.ts` is the one file that names the SQLite provider, and `src/routes.ts` is the app's own plugin: it builds the app on the `db` port and mounts it.
5
+
6
+ - **The dependency rule:** only `src/app.ts` (the `opener` in `package.json`) imports the provider. Every other file takes a `Db` (`import type { Db } from '../plugins/acryl-db/src/index.ts'`) as an argument and never imports `node:sqlite`, `pg`, another driver or `acryl-db-sqlite`. `npm test` and `npm run check` run `acrylblends check-deps`, which names the file and line of a violation; do not move the `opener` entry to silence it.
7
+ - `src/llm.ts`: `Llm`, what the app needs from a model (messages in, pieces out, an aborted signal stops the work), and `openAiCompatible`, which calls an OpenAI-compatible chat completions API with `stream: true`. The base URL is `DEEPSEEK_BASE_URL` (default `https://api.deepseek.com`), the model `DEEPSEEK_MODEL` (default `deepseek-chat`), the key **only** the environment variable `DEEPSEEK_API_KEY`, read at the moment of the call: never write it to a file, a response or a log.
8
+ - `src/db.ts`: `chatStore(db)`. The routes only call `chatStore`; keep SQL out of them. Every `db` call returns a promise: `await` it. The database is `data/chat.sqlite` (or the file `$CHAT_DB` names), opened by the `db` row with `migrations/` applied on start. The shape of the database is `migrations/NNN-name.sql`: to change it add the next numbered file and never edit one that has run. See `docs/framework/guides/data.md`.
9
+ - `src/chat-app.ts`: `createApp(db, llm)`. `POST /api/chat` answers Server-Sent Events: `meta` (the conversation id), a `token` per piece, then `done` or `error`. The request's signal (`c.req.raw.signal`) goes to the model call, so a closed tab or the Stop button stops the request to the model; what had arrived is kept.
10
+ - `src/client/chat.tsx`: the client component (own `tsconfig.json`, browser types): sends the message with `fetch`, reads the events as they arrive, shows the answer growing, and aborts on Stop. `clientModules` compiles it when the browser asks; `npm run build` compiles it once into `dist/client`. See `docs/framework/guides/client-modules.md`.
11
+ - `test/chat.test.ts` tests every route with a mock `Llm` (never the network); keep it that way. It boots the whole app on a database file in a throwaway home and takes `app.root.db`. `test/server.test.ts` starts the real server and stops it with a signal.
12
+ - To try it with a real model: `DEEPSEEK_API_KEY=... npm run dev`.
13
+
14
+ ## Before you ship
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: put it behind a login or on a private network before it is public.
@@ -0,0 +1,29 @@
1
+ # The app as data, in ACRYL's Blend format (blends.acryl.dev/v1alpha1): which plugins it mounts, in what order, with what config.
2
+ # Edit this, not app.ts, to switch a plugin off (disabled: true) or change its config. `npm run lock` records what the app is built from.
3
+ apiVersion: blends.acryl.dev/v1alpha1
4
+ kind: Blueprint
5
+ metadata:
6
+ id: acrylblends.__APP_NAME__
7
+ name: __APP_NAME__
8
+ version: 0.1.0
9
+ visibility: private # `npm run save` never pushes a private app to a public remote
10
+ spec:
11
+ runtime: cordis
12
+ parameters:
13
+ # The address the server listens on.
14
+ hostname: { type: string, default: 127.0.0.1 }
15
+ rows:
16
+ - id: router
17
+ name: acryl-router
18
+ # The db port, on one SQLite file. The file and the migrations folder are set in src/app.ts (data/chat.sqlite, or the file the CHAT_DB environment variable names).
19
+ - id: db
20
+ name: acryl-db-sqlite
21
+ - id: routes
22
+ name: routes
23
+ - id: server
24
+ name: acryl-node-server
25
+ config:
26
+ hostname: "{{parameters.hostname}}"
27
+ # 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.
28
+ - id: acryl-door
29
+ name: acryl-door
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ .acrylblends/
3
+ .acryl/
4
+ data/
5
+ *.sqlite
6
+ *.sqlite-*
@@ -0,0 +1,12 @@
1
+ CREATE TABLE conversations (
2
+ id INTEGER PRIMARY KEY,
3
+ title TEXT NOT NULL,
4
+ created_at TEXT NOT NULL
5
+ );
6
+
7
+ CREATE TABLE messages (
8
+ id INTEGER PRIMARY KEY,
9
+ conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
10
+ role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
11
+ content TEXT NOT NULL
12
+ );
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "__APP_NAME__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "imports": {
7
+ "#acryl-jsx/jsx-runtime": "./plugins/acryl-router/src/core/jsx/jsx-runtime.ts",
8
+ "#acryl-jsx/jsx-dev-runtime": "./plugins/acryl-router/src/core/jsx/jsx-dev-runtime.ts"
9
+ },
10
+ "engines": {
11
+ "node": ">=22.19.0"
12
+ },
13
+ "scripts": {
14
+ "start": "node --import ./plugins/acryl-node-server/src/register.ts src/main.ts",
15
+ "dev": "node --watch --import ./plugins/acryl-node-server/src/register.ts src/main.ts",
16
+ "test": "acrylblends check-deps && node --import ./plugins/acryl-node-server/src/register.ts --test \"test/**/*.test.{ts,tsx}\"",
17
+ "check": "tsc --noEmit && tsc --noEmit -p src/client && acrylblends check-deps",
18
+ "build": "acrylblends build-client",
19
+ "lock": "node plugins/manifest/lock.ts",
20
+ "save": "node plugins/save/cli.ts",
21
+ "upgrade": "node plugins/manifest/upgrade-cli.ts"
22
+ },
23
+ "dependencies": {
24
+ "acrylblends": "__ACRYLBLENDS_RANGE__",
25
+ "typescript": "6.0.3"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^24.0.0"
29
+ },
30
+ "acryl": {
31
+ "opener": "src/app.ts"
32
+ }
33
+ }
@@ -0,0 +1,106 @@
1
+ import { isAbsolute, join, resolve } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { Context, type Fiber } from '@deepseek-ai/cordis'
4
+ import * as db from '../plugins/acryl-db-sqlite/src/index.ts'
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 { type AppInstance, instance } from '../plugins/instance/index.ts'
9
+ import {
10
+ blueprintsIn,
11
+ compose,
12
+ loadManifest,
13
+ type ParameterValue,
14
+ } from '../plugins/manifest/index.ts'
15
+ import { doorEnabled } from './door.ts'
16
+ import { routes } from './routes.ts'
17
+
18
+ /** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
19
+ export const registry = {
20
+ 'acryl-router': router,
21
+ 'acryl-db-sqlite': db,
22
+ 'acryl-node-server': nodeServer,
23
+ 'acryl-door': door,
24
+ routes,
25
+ }
26
+
27
+ const projectRoot = fileURLToPath(new URL('..', import.meta.url))
28
+
29
+ /** The manifest next to this app's source. */
30
+ export const MANIFEST = join(projectRoot, 'blend.yaml')
31
+
32
+ /** The manifest, resolved. `createApp` and the tests read it the same way. */
33
+ export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
34
+ return loadManifest(MANIFEST, {
35
+ known: Object.keys(registry),
36
+ getBlueprint: blueprintsIn(join(projectRoot, 'blueprints')),
37
+ ...(values ? { values } : {}),
38
+ })
39
+ }
40
+
41
+ /** The manifest without some rows: without its server (and so its door) the app is reached in process, through `root.router.request`; the door is also left out in production. */
42
+ function without<M extends { plugins: readonly { use: string }[] }>(
43
+ manifest: M,
44
+ ...uses: string[]
45
+ ): M {
46
+ return {
47
+ ...manifest,
48
+ plugins: manifest.plugins.map((row) =>
49
+ uses.includes(row.use) ? { ...row, disabled: true } : row,
50
+ ),
51
+ }
52
+ }
53
+
54
+ export interface App {
55
+ readonly root: Context
56
+ /** The address the server listens on; empty when the app was made with `listen: false`. */
57
+ readonly url: string
58
+ stop(): Promise<void>
59
+ }
60
+
61
+ export interface AppOptions {
62
+ readonly values?: Readonly<Record<string, ParameterValue>>
63
+ /** The SQLite file, relative to the app folder; an absolute path is used as it is (tests give a file in a throwaway home). Default: `data/chat.sqlite`. */
64
+ readonly database?: string
65
+ /** 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. */
66
+ readonly listen?: boolean
67
+ }
68
+
69
+ /**
70
+ * The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. The `db` row opens the database and applies
71
+ * `migrations/`; the one place that names the SQLite provider is this file (`acrylblends check-deps`), so a different provider is a change here.
72
+ */
73
+ export async function createApp(chosen: AppInstance, options: AppOptions = {}): Promise<App> {
74
+ const manifest = readManifest(options.values)
75
+ const listen = options.listen
76
+ const file = options.database ?? 'data/chat.sqlite'
77
+ const root = new Context()
78
+ const first = root.plugin(instance, chosen)
79
+ await first.await()
80
+ const fibers: Fiber[] = [
81
+ first,
82
+ ...(await compose(
83
+ root,
84
+ listen === false
85
+ ? without(manifest, 'acryl-node-server', 'acryl-door')
86
+ : doorEnabled()
87
+ ? manifest
88
+ : without(manifest, 'acryl-door'),
89
+ registry,
90
+ {
91
+ db: {
92
+ path: isAbsolute(file) ? file : resolve(projectRoot, file),
93
+ migrations: join(projectRoot, 'migrations'),
94
+ },
95
+ server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
96
+ },
97
+ )),
98
+ ]
99
+ return {
100
+ root,
101
+ url: listen === false ? '' : root.nodeServer.url,
102
+ async stop() {
103
+ for (const fiber of [...fibers].reverse()) await fiber.dispose()
104
+ },
105
+ }
106
+ }
@@ -0,0 +1,101 @@
1
+ import type { Db } from '../plugins/acryl-db/src/index.ts'
2
+ import {
3
+ type StandardSchema,
4
+ schemaValidator,
5
+ streamSSE,
6
+ } from '../plugins/acryl-router/src/helpers/index.ts'
7
+ import { Acryl } from '../plugins/acryl-router/src/index.ts'
8
+ import { chatStore } from './db.ts'
9
+ import { type Llm, openAiCompatible } from './llm.ts'
10
+ import { chatPage } from './page.tsx'
11
+
12
+ interface ChatInput {
13
+ message: string
14
+ conversation?: number
15
+ }
16
+
17
+ const ChatSchema: StandardSchema<ChatInput> = {
18
+ '~standard': {
19
+ validate: (value) => {
20
+ const input = (value ?? {}) as Record<string, unknown>
21
+ const message = typeof input.message === 'string' ? input.message.trim() : ''
22
+ const issues = []
23
+ if (message === '') issues.push({ message: 'Write a message.', path: ['message'] })
24
+ else if (message.length > 4000)
25
+ issues.push({ message: 'The message is at most 4000 characters.', path: ['message'] })
26
+ if (input.conversation !== undefined && !Number.isInteger(input.conversation))
27
+ issues.push({ message: 'The conversation is a number.', path: ['conversation'] })
28
+ return issues.length > 0
29
+ ? { issues }
30
+ : { value: { message, conversation: input.conversation as number | undefined } }
31
+ },
32
+ },
33
+ }
34
+
35
+ export function createApp(db: Db, llm: Llm = openAiCompatible) {
36
+ const store = chatStore(db)
37
+ const app = new Acryl()
38
+
39
+ app.get('/', async (c) => {
40
+ const id = Number(c.req.query('c'))
41
+ const current = Number.isInteger(id) ? await store.conversation(id) : undefined
42
+ return c.html(
43
+ chatPage(
44
+ await store.conversations(),
45
+ current,
46
+ current ? await store.messages(current.id) : [],
47
+ ),
48
+ )
49
+ })
50
+
51
+ app.get('/api/conversations', async (c) => c.json({ conversations: await store.conversations() }))
52
+ app.get('/api/conversations/:id', async (c) => {
53
+ const id = Number(c.req.param('id'))
54
+ const conversation = await store.conversation(id)
55
+ return conversation
56
+ ? c.json({ conversation, messages: await store.messages(id) })
57
+ : c.json({ error: 'No such conversation.' }, 404)
58
+ })
59
+ app.delete('/api/conversations/:id', async (c) =>
60
+ (await store.remove(Number(c.req.param('id'))))
61
+ ? c.json({ ok: true })
62
+ : c.json({ error: 'No such conversation.' }, 404),
63
+ )
64
+
65
+ // The answer streams as Server-Sent Events: `meta` (the conversation id), then one `token` per piece, then `done`, or `error`.
66
+ // The request's signal aborts when the client goes away (the Stop button, a closed tab); it is passed to the model call so the upstream request stops too.
67
+ app.post('/api/chat', schemaValidator('json', ChatSchema), async (c) => {
68
+ const { message, conversation } = c.req.valid('json')
69
+ const known = conversation === undefined ? undefined : await store.conversation(conversation)
70
+ if (conversation !== undefined && !known) return c.json({ error: 'No such conversation.' }, 404)
71
+ const id = known?.id ?? (await store.start(message))
72
+ await store.add(id, 'user', message)
73
+ const history = (await store.messages(id)).map(({ role, content }) => ({ role, content }))
74
+ const signal = c.req.raw.signal
75
+ return streamSSE(c, async (stream) => {
76
+ let answer = ''
77
+ await stream.writeSSE({ event: 'meta', data: JSON.stringify({ conversation: id }) })
78
+ try {
79
+ for await (const piece of llm(history, signal)) {
80
+ answer += piece
81
+ await stream.writeSSE({ event: 'token', data: JSON.stringify(piece) })
82
+ }
83
+ await stream.writeSSE({ event: 'done', data: '{}' })
84
+ } catch (error) {
85
+ if (!signal.aborted) {
86
+ console.error(error)
87
+ // The reason stays in the log; the page gets a plain message.
88
+ await stream.writeSSE({
89
+ event: 'error',
90
+ data: JSON.stringify('The model could not answer. Try again.'),
91
+ })
92
+ }
93
+ } finally {
94
+ // What arrived is kept, also when the reader pressed Stop.
95
+ if (answer !== '') await store.add(id, 'assistant', answer)
96
+ }
97
+ })
98
+ })
99
+
100
+ return app
101
+ }
@@ -0,0 +1,115 @@
1
+ import { render, useRef, useState } from 'acrylblends/jsx/dom'
2
+
3
+ /** Read a Server-Sent Events body: calls `on(event, data)` for each event as it arrives. (EventSource only does GET, and this is a POST.) */
4
+ async function readEvents(
5
+ body: ReadableStream<Uint8Array>,
6
+ on: (event: string, data: string) => void,
7
+ ): Promise<void> {
8
+ const reader = body.getReader()
9
+ const decoder = new TextDecoder()
10
+ let buffer = ''
11
+ for (;;) {
12
+ const { done, value } = await reader.read()
13
+ if (done) return
14
+ // Line endings may be CRLF; a \r at the end of a chunk is completed by the next chunk, so normalise the whole buffer.
15
+ buffer = (buffer + decoder.decode(value, { stream: true })).replaceAll('\r\n', '\n')
16
+ const blocks = buffer.split('\n\n')
17
+ buffer = blocks.pop() ?? ''
18
+ for (const block of blocks) {
19
+ let event = 'message'
20
+ const data: string[] = []
21
+ for (const line of block.split('\n')) {
22
+ if (line.startsWith('event:')) event = line.slice(6).trim()
23
+ else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, '')) // the SSE spec strips one leading space only
24
+ }
25
+ on(event, data.join('\n'))
26
+ }
27
+ }
28
+ }
29
+
30
+ const Chat = ({ start }: { start: number | undefined }) => {
31
+ const [text, setText] = useState('')
32
+ const [asked, setAsked] = useState('')
33
+ const [answer, setAnswer] = useState('')
34
+ const [error, setError] = useState('')
35
+ const [busy, setBusy] = useState(false)
36
+ const conversation = useRef<number | undefined>(start)
37
+ const controller = useRef<AbortController | undefined>(undefined)
38
+
39
+ const send = async (event: Event) => {
40
+ event.preventDefault()
41
+ const message = text.trim()
42
+ if (message === '' || busy) return
43
+ setAsked(message)
44
+ setAnswer('')
45
+ setError('')
46
+ setText('')
47
+ setBusy(true)
48
+ controller.current = new AbortController()
49
+ try {
50
+ const response = await fetch('/api/chat', {
51
+ method: 'POST',
52
+ headers: { 'content-type': 'application/json' },
53
+ body: JSON.stringify({ message, conversation: conversation.current }),
54
+ signal: controller.current.signal,
55
+ })
56
+ if (!response.ok || !response.body) {
57
+ const problem = (await response.json().catch(() => ({}))) as {
58
+ issues?: { message: string }[]
59
+ error?: string
60
+ }
61
+ setError(problem.issues?.[0]?.message ?? problem.error ?? 'The message was not accepted.')
62
+ return
63
+ }
64
+ let soFar = ''
65
+ await readEvents(response.body, (name, data) => {
66
+ if (name === 'meta') {
67
+ conversation.current = (JSON.parse(data) as { conversation: number }).conversation
68
+ history.replaceState(null, '', `/?c=${conversation.current}`)
69
+ } else if (name === 'token') {
70
+ soFar += JSON.parse(data) as string
71
+ setAnswer(soFar)
72
+ } else if (name === 'error') setError(JSON.parse(data) as string)
73
+ })
74
+ } catch (caught) {
75
+ if (!(caught instanceof DOMException && caught.name === 'AbortError'))
76
+ setError('The connection was lost.')
77
+ } finally {
78
+ setBusy(false)
79
+ }
80
+ }
81
+
82
+ return (
83
+ <div>
84
+ {asked !== '' && <p class="row user">{asked}</p>}
85
+ {answer !== '' && <p class="row">{answer}</p>}
86
+ {error !== '' && (
87
+ <p class="row error" role="alert">
88
+ {error}
89
+ </p>
90
+ )}
91
+ <form onSubmit={send}>
92
+ <textarea
93
+ rows={3}
94
+ value={text}
95
+ placeholder="Ask something"
96
+ onInput={(e) => setText((e.target as HTMLTextAreaElement).value)}
97
+ />
98
+ <p>
99
+ <button type="submit" disabled={busy}>
100
+ Send
101
+ </button>{' '}
102
+ {busy && (
103
+ <button type="button" onClick={() => controller.current?.abort()}>
104
+ Stop
105
+ </button>
106
+ )}
107
+ </p>
108
+ </form>
109
+ </div>
110
+ )
111
+ }
112
+
113
+ const root = document.getElementById('chat')!
114
+ const start = root.dataset.conversation ? Number(root.dataset.conversation) : undefined
115
+ render(<Chat start={start} />, root)
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2024",
4
+ "lib": ["ES2024", "DOM", "DOM.Iterable"],
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "allowImportingTsExtensions": true,
10
+ "jsx": "react-jsx",
11
+ "jsxImportSource": "acrylblends/jsx/dom",
12
+ "types": [],
13
+ "paths": {
14
+ "acrylblends/jsx/dom": ["../../plugins/acryl-router/src/browser/jsx-dom.ts"],
15
+ "acrylblends/jsx/dom/jsx-runtime": [
16
+ "../../plugins/acryl-router/src/core/jsx/dom/jsx-runtime.ts"
17
+ ],
18
+ "acrylblends/jsx/dom/jsx-dev-runtime": [
19
+ "../../plugins/acryl-router/src/core/jsx/dom/jsx-dev-runtime.ts"
20
+ ],
21
+ "acrylblends/jsx/dom/client": ["../../plugins/acryl-router/src/browser/jsx-dom.ts"],
22
+ "acrylblends/client": ["../../plugins/acryl-router/src/browser/client.ts"]
23
+ }
24
+ },
25
+ "include": ["**/*.ts", "**/*.tsx"]
26
+ }
@@ -0,0 +1,43 @@
1
+ import type { Db } from '../plugins/acryl-db/src/index.ts'
2
+
3
+ export interface Message {
4
+ id: number
5
+ conversation_id: number
6
+ role: 'user' | 'assistant'
7
+ content: string
8
+ }
9
+ export interface Conversation {
10
+ id: number
11
+ title: string
12
+ }
13
+
14
+ export function chatStore(db: Db) {
15
+ return {
16
+ conversations: (): Promise<Conversation[]> =>
17
+ db.all<Conversation>('SELECT id, title FROM conversations ORDER BY id DESC'),
18
+ conversation: (id: number): Promise<Conversation | undefined> =>
19
+ db.get<Conversation>('SELECT id, title FROM conversations WHERE id = ?', id),
20
+ messages: (id: number): Promise<Message[]> =>
21
+ db.all<Message>('SELECT * FROM messages WHERE conversation_id = ? ORDER BY id', id),
22
+ async start(title: string): Promise<number> {
23
+ const row = await db.get<{ id: number }>(
24
+ 'INSERT INTO conversations (title, created_at) VALUES (?, ?) RETURNING id',
25
+ title.slice(0, 60),
26
+ new Date().toISOString(),
27
+ )
28
+ return row!.id
29
+ },
30
+ async add(conversationId: number, role: Message['role'], content: string): Promise<void> {
31
+ await db.run(
32
+ 'INSERT INTO messages (conversation_id, role, content) VALUES (?, ?, ?)',
33
+ conversationId,
34
+ role,
35
+ content,
36
+ )
37
+ },
38
+ async remove(id: number): Promise<boolean> {
39
+ const { changes } = await db.run('DELETE FROM conversations WHERE id = ?', id)
40
+ return changes > 0
41
+ },
42
+ }
43
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The door (`plugins/acryl-door`) lets an agent working in this folder look at the running page and act on it, through `.acryl/bin/acryl`. It is on in development
3
+ * and off in production (`NODE_ENV=production`) and when `ACRYL_DOOR=off`: `src/app.ts` leaves its row out then, and the pages leave its script tag out. One process
4
+ * at a time serves a folder's door, so the tests that start the real server set `ACRYL_DOOR=off` and a second `npm start` beside a running one does not fail on it.
5
+ */
6
+ export const doorEnabled = (): boolean =>
7
+ process.env.NODE_ENV !== 'production' && process.env.ACRYL_DOOR !== 'off'
@@ -0,0 +1,42 @@
1
+ /** What the app needs from a language model: the conversation so far in, the answer out piece by piece. Aborting the signal must stop the work. */
2
+ export type Llm = (
3
+ messages: { role: string; content: string }[],
4
+ signal: AbortSignal,
5
+ ) => AsyncIterable<string>
6
+
7
+ /**
8
+ * An OpenAI-compatible chat completions API with `stream: true` (DeepSeek by default). The key is read from DEEPSEEK_API_KEY at the moment of the call and
9
+ * is never stored; DEEPSEEK_BASE_URL and DEEPSEEK_MODEL choose the service.
10
+ */
11
+ export const openAiCompatible: Llm = async function* (messages, signal) {
12
+ const key = process.env.DEEPSEEK_API_KEY
13
+ if (!key) throw new Error('DEEPSEEK_API_KEY is not set')
14
+ const base = (process.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com').replace(/\/$/, '')
15
+ const response = await fetch(`${base}/chat/completions`, {
16
+ method: 'POST',
17
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
18
+ body: JSON.stringify({
19
+ model: process.env.DEEPSEEK_MODEL ?? 'deepseek-chat',
20
+ messages,
21
+ stream: true,
22
+ }),
23
+ signal,
24
+ })
25
+ if (!response.ok || !response.body)
26
+ throw new Error(`the model service answered ${response.status}`)
27
+ const decoder = new TextDecoder()
28
+ let buffer = ''
29
+ for await (const chunk of response.body) {
30
+ buffer += decoder.decode(chunk, { stream: true })
31
+ const lines = buffer.split('\n')
32
+ buffer = lines.pop() ?? ''
33
+ for (const line of lines) {
34
+ if (!line.startsWith('data:')) continue
35
+ const data = line.slice(5).trim()
36
+ if (data === '[DONE]') return
37
+ const piece = (JSON.parse(data) as { choices?: { delta?: { content?: string } }[] })
38
+ .choices?.[0]?.delta?.content
39
+ if (piece) yield piece
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,28 @@
1
+ import { fileURLToPath } from 'node:url'
2
+ import { selectInstance } from '../plugins/instance/index.ts'
3
+ import { createApp } from './app.ts'
4
+
5
+ // The composition root: the one place that reads the environment and chooses where the app lives.
6
+ const chosen = selectInstance({
7
+ projectRoot: fileURLToPath(new URL('..', import.meta.url)),
8
+ // PORT and HOST are the names hosting platforms set; ACRYLBLENDS_PORT and ACRYLBLENDS_HOST win when both are given.
9
+ env: {
10
+ ...(process.env.PORT ? { ACRYLBLENDS_PORT: process.env.PORT } : {}),
11
+ ...(process.env.HOST ? { ACRYLBLENDS_HOST: process.env.HOST } : {}),
12
+ ...process.env,
13
+ },
14
+ })
15
+ const app = await createApp(chosen, {
16
+ ...(process.env.CHAT_DB ? { database: process.env.CHAT_DB } : {}),
17
+ })
18
+ console.log(`listening on ${app.url} (data in ${chosen.home})`)
19
+
20
+ // Stop taking requests, let the ones in flight finish, then close the database (the db plugin does, when it is disposed) and exit.
21
+ const stop = async () => {
22
+ const timer = setTimeout(() => process.exit(1), 10_000)
23
+ timer.unref()
24
+ await app.stop()
25
+ process.exit(0)
26
+ }
27
+ process.once('SIGINT', stop)
28
+ process.once('SIGTERM', stop)