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.
Files changed (141) hide show
  1. package/dist/index.d.ts +13 -5
  2. package/dist/index.js +72 -13
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/README.md +2 -2
  5. package/docs/framework/UPSTREAM.md +1 -1
  6. package/docs/framework/getting-started/basic.md +1 -2
  7. package/docs/framework/getting-started/desktop.md +1 -1
  8. package/docs/framework/guides/bring-your-own-tool.md +216 -0
  9. package/docs/framework/guides/create-acrylblends.md +5 -4
  10. package/docs/framework/guides/data.md +2 -1
  11. package/docs/framework/upstream.json +3 -3
  12. package/lib/node-server.js +1 -0
  13. package/package.json +1 -1
  14. package/plugins/acryl-db/plugin.json +6 -1
  15. package/plugins/acryl-db/src/conformance.ts +224 -0
  16. package/plugins/acryl-db/upstream.json +1 -1
  17. package/plugins/acryl-db-sqlite/plugin.json +4 -1
  18. package/plugins/acryl-db-sqlite/upstream.json +1 -1
  19. package/plugins/acryl-door/LICENSE +47 -0
  20. package/plugins/acryl-door/plugin.json +14 -0
  21. package/plugins/acryl-door/src/cli/acryl.ts +207 -0
  22. package/plugins/acryl-door/src/host/api.ts +9 -0
  23. package/plugins/acryl-door/src/host/audit.ts +64 -0
  24. package/plugins/acryl-door/src/host/channel.ts +142 -0
  25. package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
  26. package/plugins/acryl-door/src/host/door-file.ts +94 -0
  27. package/plugins/acryl-door/src/host/door.ts +195 -0
  28. package/plugins/acryl-door/src/host/http.ts +80 -0
  29. package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
  30. package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
  31. package/plugins/acryl-door/src/host/page-routes.ts +65 -0
  32. package/plugins/acryl-door/src/host/shim.ts +65 -0
  33. package/plugins/acryl-door/src/host/verbs.ts +109 -0
  34. package/plugins/acryl-door/src/index.ts +133 -0
  35. package/plugins/acryl-door/src/page/api.ts +9 -0
  36. package/plugins/acryl-door/src/page/channel.ts +135 -0
  37. package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
  38. package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
  39. package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
  40. package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
  41. package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
  42. package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
  43. package/plugins/acryl-door/src/page/index.ts +40 -0
  44. package/plugins/acryl-door/src/page/indicator.ts +72 -0
  45. package/plugins/acryl-door/src/page/main.ts +7 -0
  46. package/plugins/acryl-door/src/page/protocol.ts +213 -0
  47. package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
  48. package/plugins/acryl-door/src/page/user-input.ts +24 -0
  49. package/plugins/acryl-door/upstream.json +28 -0
  50. package/plugins/acryl-inspect/upstream.json +1 -1
  51. package/plugins/acryl-kv/plugin.json +6 -1
  52. package/plugins/acryl-kv/src/conformance.ts +226 -0
  53. package/plugins/acryl-kv/upstream.json +1 -1
  54. package/plugins/acryl-kv-db/plugin.json +4 -1
  55. package/plugins/acryl-kv-db/upstream.json +1 -1
  56. package/plugins/acryl-kv-json/plugin.json +4 -1
  57. package/plugins/acryl-kv-json/upstream.json +1 -1
  58. package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
  59. package/plugins/acryl-node-server/upstream.json +2 -2
  60. package/plugins/acryl-router/src/service.ts +3 -0
  61. package/plugins/acryl-router/upstream.json +2 -2
  62. package/plugins/acryl-router-http/upstream.json +1 -1
  63. package/plugins/acryl-state/upstream.json +1 -1
  64. package/plugins/agent-panel/client/main.ts +159 -0
  65. package/plugins/agent-panel/index.ts +232 -0
  66. package/plugins/agent-panel/plugin.json +21 -0
  67. package/plugins/desktop/upstream.json +1 -1
  68. package/plugins/desktop-web/upstream.json +1 -1
  69. package/plugins/extensions/resolve.ts +16 -2
  70. package/plugins/tsconfig.json +6 -0
  71. package/shared/DEFAULTS.md +11 -6
  72. package/shared/SKILL.md +1 -1
  73. package/templates/agent/blend.yaml +3 -0
  74. package/templates/agent/src/app.ts +18 -2
  75. package/templates/agent/src/routes.ts +14 -1
  76. package/templates/agent/template.json +2 -1
  77. package/templates/agent/test/agent.test.ts +10 -4
  78. package/templates/agent/test/app.test.ts +29 -8
  79. package/templates/agent/test/builder.test.ts +34 -23
  80. package/templates/agent/test/model.test.ts +24 -19
  81. package/templates/blank/src/app.ts +25 -5
  82. package/templates/blank/test/app.test.ts +28 -9
  83. package/templates/chat/AGENTS.md +16 -0
  84. package/templates/chat/blend.yaml +32 -0
  85. package/templates/chat/gitignore +6 -0
  86. package/templates/chat/migrations/001-chat.sql +12 -0
  87. package/templates/chat/package.json +33 -0
  88. package/templates/chat/src/app.ts +107 -0
  89. package/templates/chat/src/chat-app.ts +101 -0
  90. package/templates/chat/src/client/chat.tsx +115 -0
  91. package/templates/chat/src/client/tsconfig.json +26 -0
  92. package/templates/chat/src/db.ts +43 -0
  93. package/templates/chat/src/door.ts +7 -0
  94. package/templates/chat/src/llm.ts +42 -0
  95. package/templates/chat/src/main.ts +28 -0
  96. package/templates/chat/src/page.tsx +40 -0
  97. package/templates/chat/src/routes.ts +15 -0
  98. package/templates/chat/src/runtime.ts +17 -0
  99. package/templates/chat/template.json +12 -0
  100. package/templates/chat/test/chat.test.ts +198 -0
  101. package/templates/chat/test/server.test.ts +78 -0
  102. package/templates/chat/tsconfig.json +20 -0
  103. package/templates/nodejs/AGENTS.md +9 -0
  104. package/templates/nodejs/blend.yaml +29 -0
  105. package/templates/nodejs/gitignore +3 -0
  106. package/templates/nodejs/package.json +30 -0
  107. package/templates/nodejs/src/app.ts +95 -0
  108. package/templates/nodejs/src/client/counter.tsx +12 -0
  109. package/templates/nodejs/src/client/tsconfig.json +26 -0
  110. package/templates/nodejs/src/door.ts +7 -0
  111. package/templates/nodejs/src/main.ts +25 -0
  112. package/templates/nodejs/src/page.tsx +29 -0
  113. package/templates/nodejs/src/routes.ts +40 -0
  114. package/templates/nodejs/template.json +11 -0
  115. package/templates/nodejs/test/app.test.ts +78 -0
  116. package/templates/nodejs/test/server.test.ts +83 -0
  117. package/templates/nodejs/tsconfig.json +20 -0
  118. package/templates/notes/AGENTS.md +26 -0
  119. package/templates/notes/blend.yaml +32 -0
  120. package/templates/notes/gitignore +6 -0
  121. package/templates/notes/migrations/001-notes.sql +18 -0
  122. package/templates/notes/package.json +33 -0
  123. package/templates/notes/src/app.ts +107 -0
  124. package/templates/notes/src/auth.ts +57 -0
  125. package/templates/notes/src/client/counter.tsx +21 -0
  126. package/templates/notes/src/client/tsconfig.json +26 -0
  127. package/templates/notes/src/db.ts +50 -0
  128. package/templates/notes/src/door.ts +7 -0
  129. package/templates/notes/src/main.ts +28 -0
  130. package/templates/notes/src/notes-app.ts +205 -0
  131. package/templates/notes/src/routes.ts +14 -0
  132. package/templates/notes/src/runtime.ts +17 -0
  133. package/templates/notes/src/schema.ts +38 -0
  134. package/templates/notes/src/views.tsx +136 -0
  135. package/templates/notes/template.json +12 -0
  136. package/templates/notes/test/notes.test.ts +280 -0
  137. package/templates/notes/test/server.test.ts +78 -0
  138. package/templates/notes/tsconfig.json +20 -0
  139. package/templates/todo/src/app.ts +27 -7
  140. package/templates/todo/test/todo.test.ts +47 -22
  141. package/templates/todo/upstream.json +1 -1
@@ -0,0 +1,107 @@
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 { extensions } from '../plugins/extensions/index.ts'
9
+ import { type AppInstance, instance } from '../plugins/instance/index.ts'
10
+ import {
11
+ blueprintsIn,
12
+ compose,
13
+ loadManifest,
14
+ type ParameterValue,
15
+ } from '../plugins/manifest/index.ts'
16
+ import { doorEnabled } from './door.ts'
17
+ import { routes } from './routes.ts'
18
+
19
+ /** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
20
+ export const registry = {
21
+ 'acryl-router': router,
22
+ 'acryl-db-sqlite': db,
23
+ 'acryl-node-server': nodeServer,
24
+ 'acryl-door': door,
25
+ extensions,
26
+ routes,
27
+ }
28
+
29
+ const projectRoot = fileURLToPath(new URL('..', import.meta.url))
30
+
31
+ /** The manifest next to this app's source. */
32
+ export const MANIFEST = join(projectRoot, 'blend.yaml')
33
+
34
+ /** The manifest, resolved. `createApp` and the tests read it the same way. */
35
+ export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
36
+ return loadManifest(MANIFEST, {
37
+ known: Object.keys(registry),
38
+ getBlueprint: blueprintsIn(join(projectRoot, 'blueprints')),
39
+ ...(values ? { values } : {}),
40
+ })
41
+ }
42
+
43
+ /** 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. */
44
+ function without<M extends { plugins: readonly { use: string }[] }>(
45
+ manifest: M,
46
+ ...uses: string[]
47
+ ): M {
48
+ return {
49
+ ...manifest,
50
+ plugins: manifest.plugins.map((row) =>
51
+ uses.includes(row.use) ? { ...row, disabled: true } : row,
52
+ ),
53
+ }
54
+ }
55
+
56
+ export interface App {
57
+ readonly root: Context
58
+ /** The address the server listens on; empty when the app was made with `listen: false`. */
59
+ readonly url: string
60
+ stop(): Promise<void>
61
+ }
62
+
63
+ export interface AppOptions {
64
+ readonly values?: Readonly<Record<string, ParameterValue>>
65
+ /** 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`. */
66
+ readonly database?: string
67
+ /** 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. */
68
+ readonly listen?: boolean
69
+ }
70
+
71
+ /**
72
+ * 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
73
+ * `migrations/`; the one place that names the SQLite provider is this file (`acrylblends check-deps`), so a different provider is a change here.
74
+ */
75
+ export async function createApp(chosen: AppInstance, options: AppOptions = {}): Promise<App> {
76
+ const read = readManifest(options.values)
77
+ const listen = options.listen
78
+ // Rows left out: the server (and the door, which needs it) when the app is reached in process; the door when it is switched off; live plugin installs in production.
79
+ const leaveOut = new Set<string>()
80
+ if (listen === false) leaveOut.add('acryl-node-server').add('acryl-door')
81
+ if (!doorEnabled()) leaveOut.add('acryl-door')
82
+ if (process.env.NODE_ENV === 'production') leaveOut.add('extensions')
83
+ const manifest = without(read, ...leaveOut)
84
+ const file = options.database ?? 'data/chat.sqlite'
85
+ const root = new Context()
86
+ const first = root.plugin(instance, chosen)
87
+ await first.await()
88
+ const fibers: Fiber[] = [
89
+ first,
90
+ ...(await compose(root, manifest, registry, {
91
+ db: {
92
+ path: isAbsolute(file) ? file : resolve(projectRoot, file),
93
+ migrations: join(projectRoot, 'migrations'),
94
+ },
95
+ // What is installed while the app runs lives in plugins/, next to the plugins copied from the library (plugins/library.json lists those, so they are not installed twice).
96
+ extensions: { sources: [{ dir: join(projectRoot, 'plugins'), trust: 'install' }] },
97
+ server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
98
+ })),
99
+ ]
100
+ return {
101
+ root,
102
+ url: listen === false ? '' : root.nodeServer.url,
103
+ async stop() {
104
+ for (const fiber of [...fibers].reverse()) await fiber.dispose()
105
+ },
106
+ }
107
+ }
@@ -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)
@@ -0,0 +1,40 @@
1
+ import type { Conversation, Message } from './db.ts'
2
+ import { doorEnabled } from './door.ts'
3
+
4
+ export const chatPage = (
5
+ conversations: Conversation[],
6
+ current: Conversation | undefined,
7
+ messages: Message[],
8
+ ): string =>
9
+ (
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="utf-8" />
13
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
14
+ <title>Chat</title>
15
+ <style
16
+ dangerouslySetInnerHTML={{
17
+ __html:
18
+ 'body{font:16px system-ui;max-width:48rem;margin:2rem auto;padding:0 1rem}.row{white-space:pre-wrap;margin:.75rem 0}.user{font-weight:600}.error{color:#b00020}textarea{width:100%;box-sizing:border-box;padding:.5rem}nav a{margin-right:.75rem}',
19
+ }}
20
+ />
21
+ </head>
22
+ <body>
23
+ <h1>Chat</h1>
24
+ <nav>
25
+ <a href="/">New chat</a>
26
+ {conversations.map((c) => (
27
+ <a href={`/?c=${c.id}`}>{c.title}</a>
28
+ ))}
29
+ </nav>
30
+ <div id="log">
31
+ {messages.map((m) => (
32
+ <p class={`row ${m.role}`}>{m.content}</p>
33
+ ))}
34
+ </div>
35
+ <div id="chat" data-conversation={current ? String(current.id) : ''} />
36
+ <script type="module" src="/client/chat.tsx" />
37
+ {doorEnabled() ? <script type="module" src="/api/acryl-door/page.js" /> : null}
38
+ </body>
39
+ </html>
40
+ ).toString()
@@ -0,0 +1,15 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import type {} from '../plugins/acryl-db/src/cordis.ts'
3
+ import { createApp } from './chat-app.ts'
4
+ import { openAiCompatible } from './llm.ts'
5
+ import { createClient } from './runtime.ts'
6
+
7
+ /** The app's own plugin: it builds the app on the `db` port and mounts it on the router. The mount is removed when the plugin is disposed. */
8
+ export const routes = {
9
+ name: 'routes',
10
+ inject: ['router', 'db'],
11
+ apply(ctx: Context) {
12
+ ctx.router.route('/', createApp(ctx.db, openAiCompatible))
13
+ ctx.router.route('/client', createClient())
14
+ },
15
+ }
@@ -0,0 +1,17 @@
1
+ import { createRequire } from 'node:module'
2
+ import { dirname, join } from 'node:path'
3
+ import { clientModules } from '../plugins/acryl-node-server/src/client-modules.ts'
4
+
5
+ /** The browser builds of the DOM renderer and the typed client, shipped by the `acrylblends` package this app depends on (`clientModules` serves them). */
6
+ const runtimeDir = join(
7
+ dirname(createRequire(import.meta.url).resolve('acrylblends/package.json')),
8
+ 'lib',
9
+ 'browser',
10
+ )
11
+
12
+ /**
13
+ * Client components without a bundler: `src/client` is compiled when the browser asks; `npm run build` compiles it once for production
14
+ * (`NODE_ENV=production` then serves only `dist/client`). `src/routes.ts` mounts it at `/client` on the router, next to the app.
15
+ */
16
+ export const createClient = () =>
17
+ clientModules({ root: 'src/client', compiled: 'dist/client', runtimeDir })
@@ -0,0 +1,12 @@
1
+ {
2
+ "plugins": [
3
+ "instance",
4
+ "acryl-router",
5
+ "acryl-node-server",
6
+ "acryl-db-sqlite",
7
+ "manifest",
8
+ "save",
9
+ "extensions",
10
+ "acryl-door"
11
+ ]
12
+ }