acrylblends 1.4.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/dist/index.d.ts +7 -4
- package/dist/index.js +191 -62
- package/dist/index.js.map +1 -1
- package/legacy/README.md +1 -1
- package/legacy/agent/46c6eadf9230/src/app.ts +293 -0
- package/legacy/agent/46c6eadf9230/src/routes.ts +163 -0
- package/legacy/blank/a53d41e6ef42/src/app.ts +160 -0
- package/legacy/blank/a53d41e6ef42/src/routes.ts +79 -0
- package/legacy/layouts.json +30 -0
- package/lib/{client-modules-Bkdr83jO.js → client-modules-C5qiVroz.js} +1 -1
- package/lib/cordis/door.js +1 -1
- package/lib/node-server/register.js +6 -1
- package/lib/node-server.js +1 -1
- package/lib/{transpile-QUK9b3eG.js → transpile-CRRaixyS.js} +19 -4
- package/package.json +1 -1
- package/plugins/acryl-door/src/cli/acryl.ts +6 -2
- package/plugins/acryl-node-server/src/register.ts +10 -1
- package/plugins/acryl-node-server/src/transpile.ts +25 -6
- package/plugins/acryl-page/src/favicon.ts +19 -0
- package/plugins/acryl-page/src/index.ts +18 -13
- package/plugins/agent-panel/client/main.ts +9 -0
- package/plugins/agent-panel/headless.ts +2 -2
- package/plugins/desktop/app-page.ts +151 -0
- package/plugins/desktop/assets/acrylblends-logo.png +0 -0
- package/plugins/desktop/brand.ts +164 -0
- package/plugins/desktop/frame.ts +88 -7
- package/plugins/desktop/git.ts +44 -0
- package/plugins/desktop/host-supervisor.ts +63 -0
- package/plugins/desktop/launch.ts +17 -1
- package/plugins/desktop/main.ts +268 -17
- package/plugins/desktop/preload.cjs +8 -0
- package/plugins/desktop/product.ts +117 -0
- package/plugins/desktop/window.ts +4 -0
- package/plugins/desktop-web/main.ts +3 -2
- package/plugins/desktop-web/plugin.json +1 -1
- package/plugins/extensions/index.ts +52 -7
- package/plugins/instance/archive.ts +395 -0
- package/plugins/instance/bundle-overlay.ts +10 -0
- package/plugins/instance/bundle.ts +201 -0
- package/plugins/instance/home.ts +395 -0
- package/plugins/instance/index.ts +132 -24
- package/plugins/instance/storage.ts +413 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +1 -1
- package/templates/agent/README.md +1 -1
- package/templates/agent/gitignore +2 -0
- package/templates/blank/README.md +1 -1
- package/templates/blank/gitignore +2 -0
- package/templates/blank/src/routes.ts +9 -5
- package/templates/chat/gitignore +2 -0
- package/templates/chat/src/chat-app.ts +7 -3
- package/templates/chat/src/llm.ts +7 -1
- package/templates/chat/test/chat.test.ts +16 -1
- package/templates/nodejs/gitignore +2 -0
- package/templates/notes/gitignore +2 -0
- package/templates/todo/README.md +1 -1
- package/templates/todo/gitignore +2 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import { Acryl } from '../plugins/acryl-router/src/index.ts'
|
|
3
|
+
import { type Home, renderWidgets } from '../plugins/home/index.ts'
|
|
4
|
+
import { aboutPage } from './about.tsx'
|
|
5
|
+
import { FIRST_PAGE_MARKER } from './first-page.ts'
|
|
6
|
+
|
|
7
|
+
export const APP_NAME = '__APP_NAME__'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The only place the app's mount prefix is named. On its own server (production, `npm start`) the app mounts at the root and
|
|
11
|
+
* its public URLs are `/`, `/about`, `/health`; when the ACRYL frame hosts it (`npm run dev`) the routes mount here, and the
|
|
12
|
+
* frame opens its tile at `/app` (`/app/` answers too). Every page link and asset URL this template writes is derived from the prefix it is
|
|
13
|
+
* mounted at, so the same source serves both.
|
|
14
|
+
*/
|
|
15
|
+
export const FRAME_MOUNT_PREFIX = '/app'
|
|
16
|
+
|
|
17
|
+
/** The scaffold's name comes from the folder, so the first screen escapes it like any other user text. */
|
|
18
|
+
const escapeHtml = (text: string): string =>
|
|
19
|
+
text
|
|
20
|
+
.replaceAll('&', '&')
|
|
21
|
+
.replaceAll('<', '<')
|
|
22
|
+
.replaceAll('>', '>')
|
|
23
|
+
.replaceAll('"', '"')
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The first screen: the page a browser gets at the root, which is what the ACRYL frame's first tile shows. A blank app whose root answered JSON to
|
|
27
|
+
* every client gave the tile Chrome's JSON viewer, nearly invisible in the frame's dark surface (owner review, 2026-11-02); the root now answers a page
|
|
28
|
+
* to a browser and the JSON hello to everything else, the way the agent template does. The About link is relative, so it works under the frame's /app
|
|
29
|
+
* mount and on the app's own server alike.
|
|
30
|
+
*/
|
|
31
|
+
const homePage = (name: string, widgets: string): string => `<!doctype html>
|
|
32
|
+
<html lang="en">
|
|
33
|
+
<head>
|
|
34
|
+
<meta charset="utf-8">
|
|
35
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
36
|
+
<title>${escapeHtml(name)}</title>
|
|
37
|
+
<style>:root{color-scheme:light dark;background:Canvas;color:CanvasText}body{margin:2rem auto;max-width:44rem;padding:0 1rem;font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}h1{font-size:1.6rem;margin:0 0 .5rem}a{color:#5468ff}code{font:.9em ui-monospace,"SF Mono",Menlo,monospace}</style>
|
|
38
|
+
</head>
|
|
39
|
+
<body>
|
|
40
|
+
<main>
|
|
41
|
+
<h1>${escapeHtml(name)}</h1>
|
|
42
|
+
<p>Your app is running. ${FIRST_PAGE_MARKER}; edit <code>src/routes.ts</code> to change it.</p>
|
|
43
|
+
<p><a href="./about">About</a></p>
|
|
44
|
+
${widgets}
|
|
45
|
+
</main>
|
|
46
|
+
</body>
|
|
47
|
+
</html>`
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The app's own pages and routes, mountable at any prefix: the standalone server mounts it at the root, the frame host at {@link FRAME_MOUNT_PREFIX}.
|
|
51
|
+
* The first screen shows the widgets of the app's plugins (`ctx.home`), read at each request, so a plugin switched on or off shows at the next load.
|
|
52
|
+
*/
|
|
53
|
+
export function makeApp(home: Home, name: () => string = () => APP_NAME): Acryl {
|
|
54
|
+
return (
|
|
55
|
+
new Acryl()
|
|
56
|
+
// A browser gets the first screen; an API client gets the JSON hello.
|
|
57
|
+
.get('/', async (c) =>
|
|
58
|
+
(c.req.header('accept') ?? '').includes('text/html')
|
|
59
|
+
? c.html(homePage(name(), await renderWidgets(home)))
|
|
60
|
+
: c.json({ app: name(), ok: true }),
|
|
61
|
+
)
|
|
62
|
+
.get('/health', (c) => c.json({ ok: true }))
|
|
63
|
+
.get('/about', (c) => c.html(aboutPage()))
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The app's own routes: an `App` mounted on the router at `options.prefix` (empty or absent: the root). The mount is removed when this plugin is disposed. */
|
|
68
|
+
export const routes = {
|
|
69
|
+
name: 'routes',
|
|
70
|
+
inject: ['router', 'home'],
|
|
71
|
+
apply(ctx: Context, options: { readonly prefix?: string } = {}) {
|
|
72
|
+
const prefix = options.prefix ?? ''
|
|
73
|
+
// The name the app has now (Settings > App renames it), else the one the scaffold was made with.
|
|
74
|
+
ctx.router.route(
|
|
75
|
+
prefix === '' ? '/' : prefix,
|
|
76
|
+
makeApp(ctx.home, () => ctx.get('appInstance')?.name ?? APP_NAME),
|
|
77
|
+
)
|
|
78
|
+
},
|
|
79
|
+
}
|
package/legacy/layouts.json
CHANGED
|
@@ -430,6 +430,28 @@
|
|
|
430
430
|
"notes": "fb43a1c5f3b4",
|
|
431
431
|
"todo": "164274ffcf7f"
|
|
432
432
|
}
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
"version": "1.4.0",
|
|
436
|
+
"layouts": {
|
|
437
|
+
"agent": "46c6eadf9230",
|
|
438
|
+
"blank": "a7e8f539ffee",
|
|
439
|
+
"chat": "d4464dfb3b04",
|
|
440
|
+
"nodejs": "a6a340b4d3a6",
|
|
441
|
+
"notes": "fb43a1c5f3b4",
|
|
442
|
+
"todo": "164274ffcf7f"
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
"version": "1.5.0",
|
|
447
|
+
"layouts": {
|
|
448
|
+
"agent": "46c6eadf9230",
|
|
449
|
+
"blank": "a53d41e6ef42",
|
|
450
|
+
"chat": "d4464dfb3b04",
|
|
451
|
+
"nodejs": "a6a340b4d3a6",
|
|
452
|
+
"notes": "fb43a1c5f3b4",
|
|
453
|
+
"todo": "164274ffcf7f"
|
|
454
|
+
}
|
|
433
455
|
}
|
|
434
456
|
],
|
|
435
457
|
"templates": {
|
|
@@ -485,6 +507,10 @@
|
|
|
485
507
|
"ecbc84f1c1e5": [
|
|
486
508
|
"src/app.ts",
|
|
487
509
|
"src/routes.ts"
|
|
510
|
+
],
|
|
511
|
+
"46c6eadf9230": [
|
|
512
|
+
"src/app.ts",
|
|
513
|
+
"src/routes.ts"
|
|
488
514
|
]
|
|
489
515
|
},
|
|
490
516
|
"blank": {
|
|
@@ -519,6 +545,10 @@
|
|
|
519
545
|
"a7e8f539ffee": [
|
|
520
546
|
"src/app.ts",
|
|
521
547
|
"src/routes.ts"
|
|
548
|
+
],
|
|
549
|
+
"a53d41e6ef42": [
|
|
550
|
+
"src/app.ts",
|
|
551
|
+
"src/routes.ts"
|
|
522
552
|
]
|
|
523
553
|
},
|
|
524
554
|
"chat": {
|
|
@@ -2,7 +2,7 @@ import { t as Acryl } from "./app-CQcGWjrk.js";
|
|
|
2
2
|
import "./core-BOu1jYua.js";
|
|
3
3
|
import { t as basePath } from "./route-D8m-a2I1.js";
|
|
4
4
|
import "./helpers-BEjcdbgw.js";
|
|
5
|
-
import { n as compileModule, t as MissingTypeScriptError } from "./transpile-
|
|
5
|
+
import { n as compileModule, t as MissingTypeScriptError } from "./transpile-CRRaixyS.js";
|
|
6
6
|
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
7
|
import { copyFileSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
package/lib/cordis/door.js
CHANGED
|
@@ -2,7 +2,7 @@ import { t as Acryl } from "../app-CQcGWjrk.js";
|
|
|
2
2
|
import { r as streamSSE } from "../streaming-BlDaph8U.js";
|
|
3
3
|
import { r as loopbackAuthority } from "../loopback-only-BbasoF7R.js";
|
|
4
4
|
import "../helpers-BEjcdbgw.js";
|
|
5
|
-
import { n as clientModules } from "../client-modules-
|
|
5
|
+
import { n as clientModules } from "../client-modules-C5qiVroz.js";
|
|
6
6
|
import { a as MAX_WAIT_MS, d as parseChannelToHost, l as UiControlError, p as parseUiRequest, r as MAX_SNAPSHOT_NODES, u as WINDOW_ID } from "../protocol-CQ_if7Dg.js";
|
|
7
7
|
import { a as INVALID_JSON, c as operatorOnly, d as AuditLog, f as auditPath, i as DOOR_PREFIX, l as privateApi, o as error, p as UiChannel, r as pageChannelRoutes, s as jsonBody, t as renderSnapshot, u as readJson } from "../snapshot-text-CXTnjxGx.js";
|
|
8
8
|
import "../src-iBJ2ysV9.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as compileModule } from "../transpile-
|
|
1
|
+
import { n as compileModule, r as usePackagesOfProgram } from "../transpile-CRRaixyS.js";
|
|
2
2
|
import { registerHooks } from "node:module";
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -35,6 +35,11 @@ registerHooks({ load(url, context, nextLoad) {
|
|
|
35
35
|
*/
|
|
36
36
|
const plugins = new URL("../../", import.meta.url);
|
|
37
37
|
const appRoot = new URL("../", plugins).href;
|
|
38
|
+
const family = new URL("instance/bundle.ts", plugins);
|
|
39
|
+
if (existsSync(fileURLToPath(family))) {
|
|
40
|
+
const { appTree } = await import(family.href);
|
|
41
|
+
if (appTree(process.env, fileURLToPath(appRoot)) !== void 0) usePackagesOfProgram(fileURLToPath(appRoot));
|
|
42
|
+
}
|
|
38
43
|
const resolver = new URL("extensions/resolve.ts", plugins);
|
|
39
44
|
if (existsSync(fileURLToPath(new URL("acryl-router/src/index.ts", plugins))) && existsSync(fileURLToPath(resolver))) {
|
|
40
45
|
const { routerAliases } = await import(resolver.href);
|
package/lib/node-server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { a as serve, d as RequestError, i as createAdaptorServer, n as UpgradeEmitter, o as upgradeWebSocket, r as UpgradeHub, s as getRequestListener, t as testWebSocket } from "./test-websocket-CQOSvBth.js";
|
|
2
2
|
import { t as serveStatic } from "./serve-static-C1Nkj_xB.js";
|
|
3
3
|
import { t as getConnInfo } from "./conninfo-CWJwieh4.js";
|
|
4
|
-
import { n as clientModules, t as buildClientModules } from "./client-modules-
|
|
4
|
+
import { n as clientModules, t as buildClientModules } from "./client-modules-C5qiVroz.js";
|
|
5
5
|
export { RequestError, UpgradeEmitter, UpgradeHub, buildClientModules, clientModules, createAdaptorServer, getConnInfo, getRequestListener, serve, serveStatic, testWebSocket, upgradeWebSocket };
|
|
@@ -23,10 +23,25 @@ const importSources = /* @__PURE__ */ new Map();
|
|
|
23
23
|
* @returns the compiler.
|
|
24
24
|
* @throws MissingTypeScriptError when the app has no `typescript`.
|
|
25
25
|
*/
|
|
26
|
+
/** The nearest folder from `start` up whose node_modules has `typescript`, or undefined. */
|
|
27
|
+
function typescriptOwner(start) {
|
|
28
|
+
for (let dir = start;; dir = dirname(dir)) {
|
|
29
|
+
if (existsSync(join(dir, "node_modules", "typescript", "package.json"))) return dir;
|
|
30
|
+
if (dirname(dir) === dir) return void 0;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** The Program's folder when this process is a desktop product's frame host: the person's source has no packages, the bundle has them. */
|
|
34
|
+
let programPackages;
|
|
35
|
+
/**
|
|
36
|
+
* In a desktop product's frame host, compile with the Program's packages when a file's own chain has none (`register.ts` calls this once it finds the
|
|
37
|
+
* product's app tree, plugins/instance/bundle.ts). Nowhere else: an app without `typescript` is told so.
|
|
38
|
+
*/
|
|
39
|
+
function usePackagesOfProgram(dir) {
|
|
40
|
+
programPackages = dir;
|
|
41
|
+
}
|
|
26
42
|
function compilerFor(file, what) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
else if (dirname(dir) === dir) throw new MissingTypeScriptError(file, what);
|
|
43
|
+
const owner = typescriptOwner(dirname(file)) ?? (programPackages === void 0 ? void 0 : typescriptOwner(programPackages));
|
|
44
|
+
if (owner === void 0) throw new MissingTypeScriptError(file, what);
|
|
30
45
|
const path = createRequire(join(owner, "package.json")).resolve("typescript");
|
|
31
46
|
let compiler = compilers.get(path);
|
|
32
47
|
if (compiler === void 0) {
|
|
@@ -87,4 +102,4 @@ function rewriteSpecifiers(ts, code, rewrite) {
|
|
|
87
102
|
return result + code.slice(last);
|
|
88
103
|
}
|
|
89
104
|
//#endregion
|
|
90
|
-
export { compileModule as n, MissingTypeScriptError as t };
|
|
105
|
+
export { compileModule as n, usePackagesOfProgram as r, MissingTypeScriptError as t };
|
package/package.json
CHANGED
|
@@ -196,13 +196,17 @@ export function parseInvocation(argv: readonly string[], env: NodeJS.ProcessEnv
|
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
/**
|
|
199
|
+
/**
|
|
200
|
+
* The app folder: `--dir`, or the nearest folder upward from `cwd` that has `.acryl/door.json`. When no app is running, the nearest folder that is an app (it
|
|
201
|
+
* has a `blend.yaml`), so "no running app in <that folder>" names the app. A folder that only has a `.acryl/` is not an app: the person's `~/.acryl` is ACRYL's
|
|
202
|
+
* own home, and it must never be taken for the app the command was run in.
|
|
203
|
+
*/
|
|
200
204
|
export function findApp(cwd: string, dir: string | undefined): string | undefined {
|
|
201
205
|
if (dir !== undefined) return resolve(cwd, dir)
|
|
202
206
|
let fallback: string | undefined
|
|
203
207
|
for (let at = resolve(cwd); ; at = dirname(at)) {
|
|
204
208
|
if (existsSync(join(at, STATE_DIR, DOOR_FILE))) return at
|
|
205
|
-
if (fallback === undefined && existsSync(join(at,
|
|
209
|
+
if (fallback === undefined && existsSync(join(at, 'blend.yaml'))) fallback = at
|
|
206
210
|
if (dirname(at) === at) return fallback
|
|
207
211
|
}
|
|
208
212
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { existsSync, readFileSync } from 'node:fs'
|
|
11
11
|
import { registerHooks } from 'node:module'
|
|
12
12
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
13
|
-
import { compileModule } from './transpile.ts'
|
|
13
|
+
import { compileModule, usePackagesOfProgram } from './transpile.ts'
|
|
14
14
|
|
|
15
15
|
/** The JSX runtime the docs set up; an app's tsconfig.json (or a file's own `@jsxImportSource` pragma) chooses another. */
|
|
16
16
|
const DEFAULT_IMPORT_SOURCE = 'acrylblends/jsx'
|
|
@@ -36,6 +36,15 @@ registerHooks({
|
|
|
36
36
|
*/
|
|
37
37
|
const plugins = new URL('../../', import.meta.url)
|
|
38
38
|
const appRoot = new URL('../', plugins).href
|
|
39
|
+
// A desktop product's frame host runs the Program from the bundle on an app's source, which holds only the person's files: their JSX compiles with the
|
|
40
|
+
// Program's `typescript` (the instance family says whether this is such a host; a published framework has no family beside it).
|
|
41
|
+
const family = new URL('instance/bundle.ts', plugins)
|
|
42
|
+
if (existsSync(fileURLToPath(family))) {
|
|
43
|
+
const { appTree } = (await import(family.href)) as {
|
|
44
|
+
appTree(env: NodeJS.ProcessEnv, bundle: string): unknown
|
|
45
|
+
}
|
|
46
|
+
if (appTree(process.env, fileURLToPath(appRoot)) !== undefined) usePackagesOfProgram(fileURLToPath(appRoot))
|
|
47
|
+
}
|
|
39
48
|
const resolver = new URL('extensions/resolve.ts', plugins)
|
|
40
49
|
if (existsSync(fileURLToPath(new URL('acryl-router/src/index.ts', plugins))) && existsSync(fileURLToPath(resolver))) {
|
|
41
50
|
const { routerAliases } = (await import(resolver.href)) as { routerAliases(): Record<string, string> }
|
|
@@ -36,13 +36,32 @@ const importSources = new Map<string, string>()
|
|
|
36
36
|
* @returns the compiler.
|
|
37
37
|
* @throws MissingTypeScriptError when the app has no `typescript`.
|
|
38
38
|
*/
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
let
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
else if (dirname(dir) === dir) throw new MissingTypeScriptError(file, what)
|
|
39
|
+
/** The nearest folder from `start` up whose node_modules has `typescript`, or undefined. */
|
|
40
|
+
function typescriptOwner(start: string): string | undefined {
|
|
41
|
+
for (let dir = start; ; dir = dirname(dir)) {
|
|
42
|
+
if (existsSync(join(dir, 'node_modules', 'typescript', 'package.json'))) return dir
|
|
43
|
+
if (dirname(dir) === dir) return undefined
|
|
45
44
|
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The Program's folder when this process is a desktop product's frame host: the person's source has no packages, the bundle has them. */
|
|
48
|
+
let programPackages: string | undefined
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* In a desktop product's frame host, compile with the Program's packages when a file's own chain has none (`register.ts` calls this once it finds the
|
|
52
|
+
* product's app tree, plugins/instance/bundle.ts). Nowhere else: an app without `typescript` is told so.
|
|
53
|
+
*/
|
|
54
|
+
export function usePackagesOfProgram(dir: string): void {
|
|
55
|
+
programPackages = dir
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function compilerFor(file: string, what: string): Compiler {
|
|
59
|
+
// Only the app's own node_modules chain counts: Node's global folders (NODE_PATH, which a package manager's shims may set) are not the app. In a
|
|
60
|
+
// desktop product the app is the person's source with the Program's packages.
|
|
61
|
+
const owner =
|
|
62
|
+
typescriptOwner(dirname(file)) ??
|
|
63
|
+
(programPackages === undefined ? undefined : typescriptOwner(programPackages))
|
|
64
|
+
if (owner === undefined) throw new MissingTypeScriptError(file, what)
|
|
46
65
|
const path = createRequire(join(owner, 'package.json')).resolve('typescript')
|
|
47
66
|
let compiler = compilers.get(path)
|
|
48
67
|
if (compiler === undefined) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's own icon: the ACRYL Blends logo (plugins/desktop/assets/acrylblends-logo.png at 64 px) inside an SVG, which is what a browser tab reads at 16 px. The wordmark is
|
|
3
|
+
* unreadable that small. The frontend package the page borrows its scripts from ships DeepSeek's whale as favicon.svg, so the page answers every icon request itself and
|
|
4
|
+
* never serves that file.
|
|
5
|
+
*/
|
|
6
|
+
export const ACRYL_FAVICON_SVG =
|
|
7
|
+
"<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" viewBox=\"0 0 64 64\" role=\"img\" aria-label=\"ACRYL Blends\"><image width=\"64\" height=\"64\" xlink:href=\"data:image/png;base64,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\"/></svg>"
|
|
8
|
+
|
|
9
|
+
/** The web manifest of an app page: its title and the icon above, not the frontend package's own. */
|
|
10
|
+
export function pageManifest(title: string): string {
|
|
11
|
+
return JSON.stringify({
|
|
12
|
+
name: title,
|
|
13
|
+
short_name: title,
|
|
14
|
+
start_url: './',
|
|
15
|
+
scope: './',
|
|
16
|
+
display: 'standalone',
|
|
17
|
+
icons: [{ src: 'favicon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }],
|
|
18
|
+
})
|
|
19
|
+
}
|
|
@@ -10,6 +10,7 @@ import { dirname, extname, join, normalize, sep } from 'node:path'
|
|
|
10
10
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
11
11
|
import type { Context } from '@deepseek-ai/cordis'
|
|
12
12
|
import type {} from 'acryl-web-server'
|
|
13
|
+
import { ACRYL_FAVICON_SVG, pageManifest } from './favicon.ts'
|
|
13
14
|
|
|
14
15
|
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
15
16
|
export const name = 'acryl-page'
|
|
@@ -64,12 +65,17 @@ export function clientAssetTags(indexHtml: string): string {
|
|
|
64
65
|
|
|
65
66
|
/** Our document: minimal on purpose; the frame renders into `#root`, and the taps add the boot wire before `</head>`. */
|
|
66
67
|
export function pageDocument(tags: string, title: string, taps: (html: string) => string): string {
|
|
68
|
+
// The title is an app's name, typed by a person: markup in it is text, never the page's.
|
|
69
|
+
const text = title.replace(/[&<>"']/gu, (character) => `&#${String(character.charCodeAt(0))};`)
|
|
67
70
|
const document = `<!doctype html>
|
|
68
71
|
<html lang="en">
|
|
69
72
|
<head>
|
|
70
73
|
<meta charset="utf-8" />
|
|
71
74
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
72
|
-
<title>${
|
|
75
|
+
<title>${text}</title>
|
|
76
|
+
<meta name="application-name" content="${text}" />
|
|
77
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
78
|
+
<link rel="manifest" href="/manifest.webmanifest" />
|
|
73
79
|
${tags}
|
|
74
80
|
</head>
|
|
75
81
|
<body>
|
|
@@ -143,21 +149,20 @@ export function apply(ctx: Context, config: Partial<Config> = {}): void {
|
|
|
143
149
|
await serveFile(assets, pathname.slice('/assets'.length), res, req.method ?? 'GET')
|
|
144
150
|
},
|
|
145
151
|
})
|
|
146
|
-
// Browsers ask for /favicon.ico unconditionally
|
|
152
|
+
// Browsers ask for /favicon.ico unconditionally. The icon is ours (the ACRYL mark), never the frontend package's DeepSeek whale.
|
|
153
|
+
const sendIcon = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
154
|
+
res.writeHead(200, { 'content-type': 'image/svg+xml', 'content-length': String(Buffer.byteLength(ACRYL_FAVICON_SVG)), 'cache-control': 'no-cache' })
|
|
155
|
+
res.end(req.method === 'HEAD' ? undefined : ACRYL_FAVICON_SVG)
|
|
156
|
+
}
|
|
157
|
+
for (const path of ['/favicon.ico', '/favicon.svg', '/favicon-dark.svg'])
|
|
158
|
+
ctx.webServer.register({ kind: 'exact', path, handler: sendIcon })
|
|
147
159
|
ctx.webServer.register({
|
|
148
160
|
kind: 'exact',
|
|
149
|
-
path: '/
|
|
161
|
+
path: '/manifest.webmanifest',
|
|
150
162
|
handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
151
|
-
|
|
163
|
+
const manifest = pageManifest(title)
|
|
164
|
+
res.writeHead(200, { 'content-type': 'application/manifest+json', 'content-length': String(Buffer.byteLength(manifest)) })
|
|
165
|
+
res.end(req.method === 'HEAD' ? undefined : manifest)
|
|
152
166
|
},
|
|
153
167
|
})
|
|
154
|
-
for (const file of ['favicon.svg', 'favicon-dark.svg', 'manifest.webmanifest']) {
|
|
155
|
-
ctx.webServer.register({
|
|
156
|
-
kind: 'exact',
|
|
157
|
-
path: `/${file}`,
|
|
158
|
-
handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
159
|
-
await serveFile(dist, file, res, req.method ?? 'GET')
|
|
160
|
-
},
|
|
161
|
-
})
|
|
162
|
-
}
|
|
163
168
|
}
|
|
@@ -525,6 +525,8 @@ export async function start(prefix: string = '/api/agent-panel'): Promise<void>
|
|
|
525
525
|
const watch = async (source: RunSource, own: boolean): Promise<void> => {
|
|
526
526
|
let live = true
|
|
527
527
|
let changed = false
|
|
528
|
+
// Every run the server finishes ends with `done` or `error`; a stream that stops without either lost its server, and the run with it.
|
|
529
|
+
let finished = false
|
|
528
530
|
let answer: HTMLElement | undefined
|
|
529
531
|
let answered = ''
|
|
530
532
|
// The Assistant's steps on the page, shown under its answer in one line, as the person reads what it did.
|
|
@@ -592,11 +594,18 @@ export async function start(prefix: string = '/api/agent-panel'): Promise<void>
|
|
|
592
594
|
changed = true
|
|
593
595
|
line(`plugin ${String(data.extension)}: ${String(data.action)}`, 'tool')
|
|
594
596
|
} else if (event === 'error') {
|
|
597
|
+
finished = true
|
|
595
598
|
line(String(data.message ?? 'The run failed.'), 'err')
|
|
596
599
|
} else if (event === 'done') {
|
|
600
|
+
finished = true
|
|
597
601
|
changed = changed || data.changed === true
|
|
598
602
|
}
|
|
599
603
|
}
|
|
604
|
+
if (live && working !== undefined && !finished)
|
|
605
|
+
line(
|
|
606
|
+
'The app stopped while the agent was working, and the run ended with it (`npm run dev` restarts the app when a file it runs changes). Ask again to go on.',
|
|
607
|
+
'err',
|
|
608
|
+
)
|
|
600
609
|
} catch (error) {
|
|
601
610
|
line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
|
|
602
611
|
} finally {
|
|
@@ -1,9 +1,9 @@
|
|
|
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 { type ChildProcess, spawn, spawnSync } from 'node:child_process'
|
|
3
3
|
import { readFileSync } from 'node:fs'
|
|
4
|
-
import { homedir } from 'node:os'
|
|
5
4
|
import { join } from 'node:path'
|
|
6
5
|
import type { Context } from '@deepseek-ai/cordis'
|
|
6
|
+
import { personHome } from '../instance/storage.ts'
|
|
7
7
|
import type { ChatDriver, ChatEvent } from './drivers.ts'
|
|
8
8
|
import { resolveLaunch } from './launch.ts'
|
|
9
9
|
|
|
@@ -78,7 +78,7 @@ export function onPath(program: string): boolean {
|
|
|
78
78
|
/** 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). */
|
|
79
79
|
export function piHasDeepseekKey(
|
|
80
80
|
env: NodeJS.ProcessEnv = process.env,
|
|
81
|
-
home: string =
|
|
81
|
+
home: string = personHome(env),
|
|
82
82
|
): boolean {
|
|
83
83
|
if (env.DEEPSEEK_API_KEY) return true
|
|
84
84
|
try {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the frame's App pages ask of a desktop product (app-storage.md, "What the person sees"): the first start's "Name your app" and Settings > App
|
|
3
|
+
* (name, size, show the folder, export, import, start over, the other apps, git's licence). The page asks through one IPC channel that `preload.cjs`
|
|
4
|
+
* exposes as `window.acrylBlendsApp`; this module answers, over `AppStorage` and the shell's own ports, so it is tested without Electron.
|
|
5
|
+
*
|
|
6
|
+
* Every answer is a plain result, never a thrown IPC error: the page shows `message` as it is.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AppInstance } from '../instance/index.ts'
|
|
10
|
+
import type { AppSize, AppStorage, RegisteredApp } from '../instance/storage.ts'
|
|
11
|
+
import type { ProductGit } from './git.ts'
|
|
12
|
+
|
|
13
|
+
/** The IPC channel; `preload.cjs` names the same string. */
|
|
14
|
+
export const APP_PAGE_CHANNEL = 'acrylblends:app'
|
|
15
|
+
|
|
16
|
+
/** What the page shows. `named` is false until the person names the app (the first start asks). */
|
|
17
|
+
export interface AppPageState {
|
|
18
|
+
readonly id: string
|
|
19
|
+
readonly name: string
|
|
20
|
+
readonly named: boolean
|
|
21
|
+
/** The other apps on this computer, to open one instead. */
|
|
22
|
+
readonly others: readonly {
|
|
23
|
+
readonly id: string
|
|
24
|
+
readonly name: string
|
|
25
|
+
readonly created: string
|
|
26
|
+
}[]
|
|
27
|
+
/** What the OS calls its file manager: the Show button's label. */
|
|
28
|
+
readonly fileManager: 'Finder' | 'Explorer' | 'Files'
|
|
29
|
+
/** The product ships git (its licence and source are offered on the page). */
|
|
30
|
+
readonly git: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type AppPageResult =
|
|
34
|
+
| { readonly ok: true; readonly value?: unknown }
|
|
35
|
+
| { readonly ok: false; readonly message: string }
|
|
36
|
+
|
|
37
|
+
export interface AppPagePorts {
|
|
38
|
+
readonly storage: AppStorage
|
|
39
|
+
/** The id of the app the window shows now. */
|
|
40
|
+
current(): string
|
|
41
|
+
/** A new app's starting files (the first start's seed). */
|
|
42
|
+
readonly seed: (source: string) => void
|
|
43
|
+
/**
|
|
44
|
+
* Stop the frame host, run `next` (which may move the old app's folders), and start the host on the app it returns, reloading the windows. When
|
|
45
|
+
* `next` fails, the app that ran comes back and the failure is the answer.
|
|
46
|
+
*/
|
|
47
|
+
switchTo(next: () => AppInstance): Promise<void>
|
|
48
|
+
/** The save dialog for an export (`suggested` is the file name), or undefined when the person cancels. */
|
|
49
|
+
chooseExportFile(suggested: string): Promise<string | undefined>
|
|
50
|
+
/** The open dialog for an import, or undefined when the person cancels. */
|
|
51
|
+
chooseImportFile(): Promise<string | undefined>
|
|
52
|
+
/** Open a file with the OS's own application for it. */
|
|
53
|
+
openPath(path: string): Promise<void>
|
|
54
|
+
/** The app's label changed: the process name and the window titles follow. */
|
|
55
|
+
renamed(app: RegisteredApp): void
|
|
56
|
+
readonly platform: NodeJS.Platform
|
|
57
|
+
readonly git?: ProductGit
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const fileManagerOf = (platform: NodeJS.Platform): AppPageState['fileManager'] =>
|
|
61
|
+
platform === 'darwin' ? 'Finder' : platform === 'win32' ? 'Explorer' : 'Files'
|
|
62
|
+
|
|
63
|
+
/** A file name every OS accepts, from the app's label: the export's suggested name. */
|
|
64
|
+
export function exportFileName(name: string): string {
|
|
65
|
+
const safe = [...name]
|
|
66
|
+
.filter((character) => (character.codePointAt(0) ?? 0) >= 0x20)
|
|
67
|
+
.join('')
|
|
68
|
+
.replace(/[<>:"/\\|?*]/gu, '-')
|
|
69
|
+
.replace(/[. ]+$/u, '')
|
|
70
|
+
.trim()
|
|
71
|
+
return `${safe === '' ? 'app' : safe}.acrylblend`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The handler of the page's channel: one operation and its argument, one result. */
|
|
75
|
+
export function appPageHandler(
|
|
76
|
+
ports: AppPagePorts,
|
|
77
|
+
): (op: unknown, arg: unknown) => Promise<AppPageResult> {
|
|
78
|
+
const state = (): AppPageState => {
|
|
79
|
+
const id = ports.current()
|
|
80
|
+
const app = ports.storage.app(id)
|
|
81
|
+
return {
|
|
82
|
+
id,
|
|
83
|
+
name: app.name,
|
|
84
|
+
named: app.named === true,
|
|
85
|
+
others: ports.storage
|
|
86
|
+
.apps()
|
|
87
|
+
.filter((other) => other.id !== id)
|
|
88
|
+
.map((other) => ({ id: other.id, name: other.name, created: other.created })),
|
|
89
|
+
fileManager: fileManagerOf(ports.platform),
|
|
90
|
+
git: ports.git !== undefined,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const run = async (op: unknown, arg: unknown): Promise<unknown> => {
|
|
94
|
+
switch (op) {
|
|
95
|
+
case 'state':
|
|
96
|
+
return state()
|
|
97
|
+
case 'size':
|
|
98
|
+
return (await ports.storage.size(ports.current())) satisfies AppSize
|
|
99
|
+
case 'rename': {
|
|
100
|
+
if (typeof arg !== 'string') throw new Error('a name is text')
|
|
101
|
+
ports.renamed(ports.storage.rename(ports.current(), arg))
|
|
102
|
+
return state()
|
|
103
|
+
}
|
|
104
|
+
case 'reveal':
|
|
105
|
+
ports.storage.reveal(ports.current())
|
|
106
|
+
return undefined
|
|
107
|
+
case 'export': {
|
|
108
|
+
const data = (arg as { data?: unknown } | undefined)?.data === true
|
|
109
|
+
const id = ports.current()
|
|
110
|
+
const file = await ports.chooseExportFile(exportFileName(ports.storage.app(id).name))
|
|
111
|
+
if (file === undefined) return { cancelled: true }
|
|
112
|
+
ports.storage.export(id, file, { data })
|
|
113
|
+
return { file }
|
|
114
|
+
}
|
|
115
|
+
case 'import': {
|
|
116
|
+
const file = await ports.chooseImportFile()
|
|
117
|
+
if (file === undefined) return { cancelled: true }
|
|
118
|
+
// Made beside the running app, which keeps running until the new one is ready to open.
|
|
119
|
+
const made = ports.storage.import(file)
|
|
120
|
+
await ports.switchTo(() => ports.storage.open(made.id))
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
case 'startOver': {
|
|
124
|
+
const id = ports.current()
|
|
125
|
+
await ports.switchTo(() => ports.storage.open(ports.storage.reset(id, ports.seed).id))
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
case 'open': {
|
|
129
|
+
if (typeof arg !== 'string' || !ports.storage.apps().some((app) => app.id === arg))
|
|
130
|
+
throw new Error('there is no such app on this computer')
|
|
131
|
+
if (arg !== ports.current()) await ports.switchTo(() => ports.storage.open(arg))
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
case 'gitLicence':
|
|
135
|
+
case 'gitSource': {
|
|
136
|
+
if (ports.git === undefined) throw new Error('this app ships no git of its own')
|
|
137
|
+
await ports.openPath(op === 'gitLicence' ? ports.git.licence : ports.git.source)
|
|
138
|
+
return undefined
|
|
139
|
+
}
|
|
140
|
+
default:
|
|
141
|
+
throw new Error(`the app page cannot ${String(op)}`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return async (op, arg) => {
|
|
145
|
+
try {
|
|
146
|
+
return { ok: true, value: await run(op, arg) }
|
|
147
|
+
} catch (cause) {
|
|
148
|
+
return { ok: false, message: cause instanceof Error ? cause.message : String(cause) }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
Binary file
|