lavish-axi 0.1.10 → 0.1.12
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 +11 -8
- package/dist/chrome-client.js +58 -5
- package/dist/cli.mjs +178 -62
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -95,9 +95,10 @@ pnpm link
|
|
|
95
95
|
```
|
|
96
96
|
|
|
97
97
|
- **File-path identity** - Sessions are keyed by the canonical HTML file path, so agents do not need opaque IDs.
|
|
98
|
-
- **
|
|
98
|
+
- **Portable artifacts** - The artifact runs in an iframe while Lavish injects a small SDK for annotations, snapshots, and feedback controls. Lavish does not inject any design system, so the saved HTML file renders identically whether you open it through `lavish-axi` or directly in a browser. Run `lavish-axi design` for a copy-pasteable CDN snippet to opt in to Tailwind CSS v4 + DaisyUI v5, or use any other design system (or none).
|
|
99
|
+
- **Live reload** - Lavish watches the HTML artifact file by default. To also reload on sibling asset changes, add `data-lavish-live-reload-root` to the root element or `<meta name="lavish-live-reload" content="root">`.
|
|
99
100
|
- **Feedback controls** - Mark buttons, choices, and other interactive elements with `data-lavish-action` so Lavish does not annotate them, then call `window.lavish.queuePrompt()` or `window.lavish.sendQueuedPrompts()` from the control handler.
|
|
100
|
-
- **Agent presence** - The browser shows when no agent is listening,
|
|
101
|
+
- **Agent presence** - The browser shows when no agent is listening, keeps queued feedback for the next successful `lavish-axi poll` send even across reloads, and only blocks sending while the agent is working on delivered feedback.
|
|
101
102
|
- **Precise targets** - Text annotations include selected text plus range anchors, so agents are not limited to whole-element selectors.
|
|
102
103
|
- **Local-first state** - Session state stays under `.lavish-axi/` in the workspace.
|
|
103
104
|
|
|
@@ -110,17 +111,19 @@ pnpm link
|
|
|
110
111
|
| `lavish-axi poll <html-file>` | Long-poll until the user sends feedback or ends the session. |
|
|
111
112
|
| `lavish-axi end <html-file>` | End a session. |
|
|
112
113
|
| `lavish-axi playbook [id]` | List focused artifact guidance or show one playbook. |
|
|
113
|
-
| `lavish-axi design` | Show
|
|
114
|
+
| `lavish-axi design` | Show CDN snippet + DaisyUI component reference (opt-in). |
|
|
115
|
+
| `lavish-axi server` | Run the local Lavish Editor server. |
|
|
114
116
|
|
|
115
117
|
Known playbook IDs: `diagram`, `table`, `comparison`, `plan`, `diff`, `input`, `slides`.
|
|
116
118
|
|
|
117
119
|
### Flags
|
|
118
120
|
|
|
119
|
-
| Command | Flag | Description
|
|
120
|
-
| ------------------------ | --------------------- |
|
|
121
|
-
| `lavish-axi <html-file>` | `--no-open` | Ensure the server/session exists without opening another browser window.
|
|
122
|
-
| `lavish-axi poll` | `--agent-reply "..."` | Show the agent's reply in the existing browser chat before polling again.
|
|
123
|
-
| `lavish-axi poll` | `--timeout-ms <ms>` | Test/debug escape hatch only; agents should normally omit it.
|
|
121
|
+
| Command | Flag | Description |
|
|
122
|
+
| ------------------------ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
123
|
+
| `lavish-axi <html-file>` | `--no-open` | Ensure the server/session exists without opening another browser window. |
|
|
124
|
+
| `lavish-axi poll` | `--agent-reply "..."` | Show the agent's reply in the existing browser chat before polling again. |
|
|
125
|
+
| `lavish-axi poll` | `--timeout-ms <ms>` | Test/debug escape hatch only; agents should normally omit it. |
|
|
126
|
+
| `lavish-axi server` | `--verbose` | Log session and watcher events to stderr; can also be enabled with `LAVISH_AXI_DEBUG=1`. Detached server output is appended to `~/.lavish-axi/server.log` (or `LAVISH_AXI_STATE_DIR/server.log`) for startup and crash diagnostics. |
|
|
124
127
|
|
|
125
128
|
## Development
|
|
126
129
|
|
package/dist/chrome-client.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const sessionDataElement = document.getElementById("lavish-session");
|
|
4
4
|
const sessionData = JSON.parse(sessionDataElement?.textContent || "{}");
|
|
5
5
|
const key = String(sessionData.key || "");
|
|
6
|
+
const queueStorageKey = "lavish-axi:queued:" + key;
|
|
6
7
|
const initialChat = Array.isArray(sessionData.initialChat) ? sessionData.initialChat : [];
|
|
7
8
|
|
|
8
9
|
const frame = /** @type {HTMLIFrameElement} */ (document.getElementById("artifact"));
|
|
@@ -16,11 +17,13 @@ const filePathInput = /** @type {HTMLInputElement} */ (document.getElementById("
|
|
|
16
17
|
const copyPathButton = /** @type {HTMLButtonElement} */ (document.getElementById("copyPath"));
|
|
17
18
|
const presenceBanner = /** @type {HTMLDivElement} */ (document.getElementById("presenceBanner"));
|
|
18
19
|
|
|
19
|
-
const queued =
|
|
20
|
+
const queued = loadQueuedPrompts();
|
|
20
21
|
let annotation = true;
|
|
21
22
|
let agentPresence = "waiting";
|
|
22
23
|
let pendingSnapshot = "";
|
|
23
24
|
let workingBubble = null;
|
|
25
|
+
let submitQueuedPromise = null;
|
|
26
|
+
let submitQueuedAgain = false;
|
|
24
27
|
|
|
25
28
|
function escapeHtml(value) {
|
|
26
29
|
return String(value).replace(
|
|
@@ -36,6 +39,27 @@ function escapeHtml(value) {
|
|
|
36
39
|
);
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
function loadQueuedPrompts() {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(sessionStorage.getItem(queueStorageKey) || "[]");
|
|
45
|
+
return Array.isArray(parsed) ? parsed.filter((prompt) => prompt && typeof prompt === "object") : [];
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function persistQueuedPrompts() {
|
|
52
|
+
try {
|
|
53
|
+
if (queued.length) {
|
|
54
|
+
sessionStorage.setItem(queueStorageKey, JSON.stringify(queued));
|
|
55
|
+
} else {
|
|
56
|
+
sessionStorage.removeItem(queueStorageKey);
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// The in-memory queue still works if browser storage is unavailable.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
39
63
|
function render() {
|
|
40
64
|
annotationPills.innerHTML = queued
|
|
41
65
|
.map(
|
|
@@ -105,6 +129,7 @@ function setAgentPresence(state) {
|
|
|
105
129
|
function removeQueuedPrompt(index, event) {
|
|
106
130
|
if (event) event.stopPropagation();
|
|
107
131
|
queued.splice(index, 1);
|
|
132
|
+
persistQueuedPrompts();
|
|
108
133
|
render();
|
|
109
134
|
}
|
|
110
135
|
|
|
@@ -118,6 +143,7 @@ function sendQueued() {
|
|
|
118
143
|
const text = chatInput.value.trim();
|
|
119
144
|
if (text) {
|
|
120
145
|
queued.push({ uid: "", prompt: text, selector: "", tag: "message", text: "Freeform message" });
|
|
146
|
+
persistQueuedPrompts();
|
|
121
147
|
addChat("user", text);
|
|
122
148
|
chatInput.value = "";
|
|
123
149
|
render();
|
|
@@ -128,14 +154,40 @@ function sendQueued() {
|
|
|
128
154
|
}
|
|
129
155
|
|
|
130
156
|
async function submitQueued() {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
157
|
+
if (submitQueuedPromise) {
|
|
158
|
+
submitQueuedAgain = true;
|
|
159
|
+
return submitQueuedPromise;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let succeeded = false;
|
|
163
|
+
submitQueuedPromise = submitQueuedOnce();
|
|
164
|
+
try {
|
|
165
|
+
const result = await submitQueuedPromise;
|
|
166
|
+
succeeded = true;
|
|
167
|
+
return result;
|
|
168
|
+
} finally {
|
|
169
|
+
submitQueuedPromise = null;
|
|
170
|
+
const shouldSubmitAgain = submitQueuedAgain;
|
|
171
|
+
submitQueuedAgain = false;
|
|
172
|
+
if (succeeded && shouldSubmitAgain && queued.length) submitQueued();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function submitQueuedOnce() {
|
|
177
|
+
const prompts = queued.slice();
|
|
178
|
+
const response = await fetch("/api/" + key + "/prompts", {
|
|
135
179
|
method: "POST",
|
|
136
180
|
headers: { "content-type": "application/json" },
|
|
137
181
|
body: JSON.stringify({ prompts, domSnapshot: pendingSnapshot }),
|
|
138
182
|
});
|
|
183
|
+
if (!response.ok) throw new Error("failed to submit queued prompts");
|
|
184
|
+
for (const prompt of prompts) {
|
|
185
|
+
const index = queued.indexOf(prompt);
|
|
186
|
+
if (index !== -1) queued.splice(index, 1);
|
|
187
|
+
}
|
|
188
|
+
persistQueuedPrompts();
|
|
189
|
+
render();
|
|
190
|
+
if (agentPresence === "listening") setAgentPresence("working");
|
|
139
191
|
}
|
|
140
192
|
|
|
141
193
|
async function endSession() {
|
|
@@ -185,6 +237,7 @@ window.addEventListener("message", (event) => {
|
|
|
185
237
|
const msg = event.data || {};
|
|
186
238
|
if (msg.type === "lavish:queuePrompt") {
|
|
187
239
|
queued.push(msg.prompt);
|
|
240
|
+
persistQueuedPrompts();
|
|
188
241
|
render();
|
|
189
242
|
}
|
|
190
243
|
if (msg.type === "lavish:snapshot") {
|
package/dist/cli.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.js
|
|
4
4
|
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { closeSync, existsSync, openSync, readFileSync } from "node:fs";
|
|
6
6
|
import { access } from "node:fs/promises";
|
|
7
7
|
import os2 from "node:os";
|
|
8
8
|
import path4 from "node:path";
|
|
@@ -10,7 +10,17 @@ import { fileURLToPath } from "node:url";
|
|
|
10
10
|
import { AxiError, runAxiCli } from "axi-sdk-js";
|
|
11
11
|
|
|
12
12
|
// src/design-reference.js
|
|
13
|
-
var
|
|
13
|
+
var TAILWIND_BROWSER_VERSION = "4.2.4";
|
|
14
|
+
var DAISYUI_VERSION = "5.5.19";
|
|
15
|
+
var DESIGN_CDN_URLS = {
|
|
16
|
+
tailwind: `https://cdn.jsdelivr.net/npm/@tailwindcss/browser@${TAILWIND_BROWSER_VERSION}/dist/index.global.js`,
|
|
17
|
+
daisyui: `https://cdn.jsdelivr.net/npm/daisyui@${DAISYUI_VERSION}/daisyui.css`,
|
|
18
|
+
daisyuiThemes: `https://cdn.jsdelivr.net/npm/daisyui@${DAISYUI_VERSION}/themes.css`
|
|
19
|
+
};
|
|
20
|
+
var DESIGN_CDN_SNIPPET = `<link rel="stylesheet" href="${DESIGN_CDN_URLS.daisyui}">
|
|
21
|
+
<link rel="stylesheet" href="${DESIGN_CDN_URLS.daisyuiThemes}">
|
|
22
|
+
<script src="${DESIGN_CDN_URLS.tailwind}"></script>`;
|
|
23
|
+
var DESIGN_SYSTEM_HINT = "Lavish does not auto-inject any design system - artifacts stay portable so they render identically when opened directly without lavish-axi running. Tailwind CSS browser runtime v4 and DaisyUI v5 are available via CDN; run `lavish-axi design` for a copy-pasteable CDN snippet plus component reference. Prefer that CDN snippet over hand-writing styles unless explicitly instructed otherwise by the user. If the user asks for any other design system or plain HTML, follow that request.";
|
|
14
24
|
var DAISYUI_THEMES = [
|
|
15
25
|
"light",
|
|
16
26
|
"dark",
|
|
@@ -51,11 +61,13 @@ var DAISYUI_THEMES = [
|
|
|
51
61
|
function createDesignOutput() {
|
|
52
62
|
return {
|
|
53
63
|
design: {
|
|
54
|
-
summary: "Lavish
|
|
55
|
-
|
|
56
|
-
|
|
64
|
+
summary: "Lavish does not auto-inject any design system. Artifacts stay portable HTML. Paste the CDN snippet below into your `<head>` to use Tailwind CSS browser runtime v4 + DaisyUI v5 + themes. Prefer this CDN snippet over hand-writing styles unless explicitly instructed otherwise by the user.",
|
|
65
|
+
cdn_snippet: DESIGN_CDN_SNIPPET,
|
|
66
|
+
cdn_urls: DESIGN_CDN_URLS,
|
|
67
|
+
versions: { tailwind: TAILWIND_BROWSER_VERSION, daisyui: DAISYUI_VERSION },
|
|
57
68
|
latest_docs: "https://daisyui.com/components/",
|
|
58
|
-
docs_note: "Use this command for common syntax. Read the latest DaisyUI docs for full details when using advanced or unfamiliar components."
|
|
69
|
+
docs_note: "Use this command for common syntax. Read the latest DaisyUI docs for full details when using advanced or unfamiliar components.",
|
|
70
|
+
other_design_systems: "If the user asks for a different design system (Bootstrap, custom CSS, plain HTML, etc.), use that instead - Lavish does not require DaisyUI."
|
|
59
71
|
},
|
|
60
72
|
theme_usage: [
|
|
61
73
|
'Set the page theme with `<html data-theme="luxury">`.',
|
|
@@ -460,12 +472,16 @@ function createDesignOutput() {
|
|
|
460
472
|
import { mkdir } from "node:fs/promises";
|
|
461
473
|
import os from "node:os";
|
|
462
474
|
import path from "node:path";
|
|
475
|
+
var LOOPBACK_HOST = "127.0.0.1";
|
|
463
476
|
function stateDir() {
|
|
464
477
|
return process.env.LAVISH_AXI_STATE_DIR || path.join(os.homedir(), ".lavish-axi");
|
|
465
478
|
}
|
|
466
479
|
function stateFile() {
|
|
467
480
|
return path.join(stateDir(), "state.json");
|
|
468
481
|
}
|
|
482
|
+
function serverLogFile() {
|
|
483
|
+
return path.join(stateDir(), "server.log");
|
|
484
|
+
}
|
|
469
485
|
async function ensureStateDir() {
|
|
470
486
|
await mkdir(stateDir(), { recursive: true });
|
|
471
487
|
}
|
|
@@ -992,25 +1008,11 @@ function createArtifactSdk() {
|
|
|
992
1008
|
// src/html-transform.js
|
|
993
1009
|
function injectLavishSdk(html, key) {
|
|
994
1010
|
const script = `<script src="/sdk.js?key=${encodeURIComponent(key)}"></script>`;
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
if (/<\/body\s*>/i.test(withDesign)) {
|
|
998
|
-
return withDesign.replace(/<\/body\s*>/i, `${script}</body>`);
|
|
999
|
-
}
|
|
1000
|
-
return `${withDesign}
|
|
1001
|
-
${script}`;
|
|
1002
|
-
}
|
|
1003
|
-
function shouldInjectDesign(html) {
|
|
1004
|
-
if (/data-lavish-design/i.test(html)) return false;
|
|
1005
|
-
return !/<meta\b(?=[^>]*name=["']lavish-design["'])(?=[^>]*content=["']off["'])[^>]*>/i.test(html);
|
|
1006
|
-
}
|
|
1007
|
-
function injectDesignAssets(html, design) {
|
|
1008
|
-
if (!design) return html;
|
|
1009
|
-
if (/<head\b[^>]*>/i.test(html)) {
|
|
1010
|
-
return html.replace(/<head\b[^>]*>/i, (match) => `${match}${design}`);
|
|
1011
|
+
if (/<\/body\s*>/i.test(html)) {
|
|
1012
|
+
return html.replace(/<\/body\s*>/i, `${script}</body>`);
|
|
1011
1013
|
}
|
|
1012
1014
|
return `${html}
|
|
1013
|
-
${
|
|
1015
|
+
${script}`;
|
|
1014
1016
|
}
|
|
1015
1017
|
|
|
1016
1018
|
// src/session-store.js
|
|
@@ -1181,7 +1183,7 @@ var designAssetUrls = {
|
|
|
1181
1183
|
type: "application/javascript"
|
|
1182
1184
|
}
|
|
1183
1185
|
};
|
|
1184
|
-
async function serve({ port, stateFile: stateFile2, version = "" }) {
|
|
1186
|
+
async function serve({ port, stateFile: stateFile2, version = "", debug = false, log = null, pollHeartbeatMs = 15e3 }) {
|
|
1185
1187
|
const app = express();
|
|
1186
1188
|
const store = new SessionStore(stateFile2);
|
|
1187
1189
|
const events = new EventEmitter();
|
|
@@ -1189,6 +1191,11 @@ async function serve({ port, stateFile: stateFile2, version = "" }) {
|
|
|
1189
1191
|
const activePolls = /* @__PURE__ */ new Map();
|
|
1190
1192
|
const deliveredFeedback = /* @__PURE__ */ new Set();
|
|
1191
1193
|
const sseClients = /* @__PURE__ */ new Set();
|
|
1194
|
+
const verbose = debug || process.env.LAVISH_AXI_DEBUG === "1";
|
|
1195
|
+
const writeLog = typeof log === "function" ? log : (line) => process.stderr.write(`${line}
|
|
1196
|
+
`);
|
|
1197
|
+
const logEvent = verbose ? (line) => writeLog(`[lavish] ${line}`) : null;
|
|
1198
|
+
let publicPort = port;
|
|
1192
1199
|
app.use(express.json({ limit: "2mb" }));
|
|
1193
1200
|
app.get("/health", (req, res) => {
|
|
1194
1201
|
res.json({ ok: true, app: "lavish-axi", version });
|
|
@@ -1205,13 +1212,14 @@ async function serve({ port, stateFile: stateFile2, version = "" }) {
|
|
|
1205
1212
|
try {
|
|
1206
1213
|
const file = await canonicalFile(req.body.file);
|
|
1207
1214
|
const key = sessionKey(file);
|
|
1208
|
-
const url = `http
|
|
1215
|
+
const url = `http://${LOOPBACK_HOST}:${publicPort}/session/${key}`;
|
|
1209
1216
|
const existing = await store.findByKey(key);
|
|
1210
1217
|
const session = await store.upsertSession(file, url);
|
|
1211
1218
|
if (existing?.status === "ended") {
|
|
1212
1219
|
clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
|
|
1213
1220
|
}
|
|
1214
|
-
|
|
1221
|
+
logEvent?.(`session opened key=${key} file=${file}`);
|
|
1222
|
+
await watchSession(session, watchers, events, logEvent);
|
|
1215
1223
|
res.json({ key, file, url, status: "opened" });
|
|
1216
1224
|
} catch (error) {
|
|
1217
1225
|
next(error);
|
|
@@ -1219,6 +1227,14 @@ async function serve({ port, stateFile: stateFile2, version = "" }) {
|
|
|
1219
1227
|
});
|
|
1220
1228
|
app.get("/api/poll", async (req, res, next) => {
|
|
1221
1229
|
try {
|
|
1230
|
+
let handleRespondError = function(error) {
|
|
1231
|
+
if (streamHeartbeat) {
|
|
1232
|
+
cleanup();
|
|
1233
|
+
if (!res.writableEnded) res.destroy(error);
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
next(error);
|
|
1237
|
+
};
|
|
1222
1238
|
const file = await canonicalFile(String(req.query.file || ""));
|
|
1223
1239
|
const key = sessionKey(file);
|
|
1224
1240
|
const timeoutMs = req.query.timeoutMs === void 0 ? null : Math.max(0, Math.min(Number(req.query.timeoutMs || 0), 2147483647));
|
|
@@ -1228,34 +1244,49 @@ async function serve({ port, stateFile: stateFile2, version = "" }) {
|
|
|
1228
1244
|
res.json(immediate);
|
|
1229
1245
|
return;
|
|
1230
1246
|
}
|
|
1247
|
+
const streamHeartbeat = timeoutMs === null;
|
|
1248
|
+
let heartbeat = null;
|
|
1249
|
+
if (streamHeartbeat) {
|
|
1250
|
+
res.status(200).type("application/json");
|
|
1251
|
+
res.write(" ");
|
|
1252
|
+
heartbeat = setInterval(() => {
|
|
1253
|
+
if (!res.writableEnded) res.write(" ");
|
|
1254
|
+
}, pollHeartbeatMs);
|
|
1255
|
+
heartbeat.unref?.();
|
|
1256
|
+
}
|
|
1231
1257
|
setPollActive(key, activePolls, deliveredFeedback, events, true);
|
|
1232
|
-
const timer = timeoutMs === null ? null : setTimeout(() => respond().catch(
|
|
1258
|
+
const timer = timeoutMs === null ? null : setTimeout(() => respond().catch(handleRespondError), timeoutMs);
|
|
1233
1259
|
let cleaned = false;
|
|
1234
1260
|
let responding = false;
|
|
1235
1261
|
const cleanup = () => {
|
|
1236
1262
|
if (cleaned) return;
|
|
1237
1263
|
cleaned = true;
|
|
1238
1264
|
if (timer) clearTimeout(timer);
|
|
1265
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
1239
1266
|
events.off("feedback", onFeedback);
|
|
1240
1267
|
events.off("ended", onFeedback);
|
|
1241
1268
|
setPollActive(key, activePolls, deliveredFeedback, events, false);
|
|
1242
1269
|
};
|
|
1243
1270
|
const respond = async () => {
|
|
1244
|
-
if (responding || res.
|
|
1271
|
+
if (responding || res.writableEnded) return;
|
|
1245
1272
|
responding = true;
|
|
1246
1273
|
try {
|
|
1247
1274
|
const result = await store.takeFeedback(key);
|
|
1248
1275
|
if (result.status === "feedback") markFeedbackDelivered(key, activePolls, deliveredFeedback, events);
|
|
1249
|
-
|
|
1276
|
+
if (streamHeartbeat) {
|
|
1277
|
+
res.end(JSON.stringify(result));
|
|
1278
|
+
} else {
|
|
1279
|
+
res.json(result);
|
|
1280
|
+
}
|
|
1250
1281
|
} finally {
|
|
1251
1282
|
cleanup();
|
|
1252
1283
|
}
|
|
1253
1284
|
};
|
|
1254
1285
|
const onFeedback = (changedKey) => {
|
|
1255
|
-
if (changedKey !== key || res.
|
|
1286
|
+
if (changedKey !== key || res.writableEnded) {
|
|
1256
1287
|
return;
|
|
1257
1288
|
}
|
|
1258
|
-
respond().catch(
|
|
1289
|
+
respond().catch(handleRespondError);
|
|
1259
1290
|
};
|
|
1260
1291
|
events.on("feedback", onFeedback);
|
|
1261
1292
|
events.on("ended", onFeedback);
|
|
@@ -1320,7 +1351,7 @@ async function serve({ port, stateFile: stateFile2, version = "" }) {
|
|
|
1320
1351
|
res.status(404).send("Session not found");
|
|
1321
1352
|
return;
|
|
1322
1353
|
}
|
|
1323
|
-
watchSession(session, watchers, events);
|
|
1354
|
+
await watchSession(session, watchers, events, logEvent);
|
|
1324
1355
|
res.type("html").send(createChromeHtml(session));
|
|
1325
1356
|
} catch (error) {
|
|
1326
1357
|
next(error);
|
|
@@ -1449,8 +1480,9 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
|
|
|
1449
1480
|
res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
|
|
1450
1481
|
});
|
|
1451
1482
|
const httpServer = await new Promise((resolve) => {
|
|
1452
|
-
const s = app.listen(port,
|
|
1483
|
+
const s = app.listen(port, LOOPBACK_HOST, () => resolve(s));
|
|
1453
1484
|
});
|
|
1485
|
+
publicPort = httpServer.address().port;
|
|
1454
1486
|
let shuttingDown = false;
|
|
1455
1487
|
function shutdown() {
|
|
1456
1488
|
if (shuttingDown) return;
|
|
@@ -1498,23 +1530,55 @@ function resolveArtifactAsset(root, assetPath) {
|
|
|
1498
1530
|
}
|
|
1499
1531
|
return file;
|
|
1500
1532
|
}
|
|
1501
|
-
function watchSession(session, watchers, events) {
|
|
1533
|
+
async function watchSession(session, watchers, events, logEvent) {
|
|
1502
1534
|
if (watchers.has(session.key)) {
|
|
1503
1535
|
return;
|
|
1504
1536
|
}
|
|
1505
|
-
const
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1537
|
+
const target = await resolveWatchTarget(session);
|
|
1538
|
+
if (watchers.has(session.key)) {
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
logEvent?.(`watch session=${session.key} scope=${target.scope} path=${target.path}`);
|
|
1542
|
+
const watcher = chokidar.watch(target.path, target.options);
|
|
1511
1543
|
let timer = null;
|
|
1512
|
-
watcher.on("all", () => {
|
|
1544
|
+
watcher.on("all", (event, file) => {
|
|
1545
|
+
logEvent?.(`watch event=${event} session=${session.key} file=${file ?? ""}`);
|
|
1513
1546
|
clearTimeout(timer);
|
|
1514
1547
|
timer = setTimeout(() => events.emit("reload", session.key), 100);
|
|
1515
1548
|
});
|
|
1549
|
+
watcher.on("error", (error) => {
|
|
1550
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1551
|
+
logEvent?.(`watch error session=${session.key} message=${message}`);
|
|
1552
|
+
});
|
|
1516
1553
|
watchers.set(session.key, watcher);
|
|
1517
1554
|
}
|
|
1555
|
+
async function resolveWatchTarget(session) {
|
|
1556
|
+
const baseOptions = {
|
|
1557
|
+
ignoreInitial: true,
|
|
1558
|
+
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }
|
|
1559
|
+
};
|
|
1560
|
+
try {
|
|
1561
|
+
const html = await readFile2(session.file, "utf8");
|
|
1562
|
+
if (hasLiveReloadRootOptIn(html)) {
|
|
1563
|
+
return {
|
|
1564
|
+
path: path3.dirname(session.file),
|
|
1565
|
+
scope: "directory",
|
|
1566
|
+
options: {
|
|
1567
|
+
...baseOptions,
|
|
1568
|
+
ignored: /(^|[/\\])(\.git|node_modules|dist|build|\.lavish-axi)([/\\]|$)/
|
|
1569
|
+
}
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
} catch {
|
|
1573
|
+
}
|
|
1574
|
+
return { path: session.file, scope: "file", options: baseOptions };
|
|
1575
|
+
}
|
|
1576
|
+
function hasLiveReloadRootOptIn(html) {
|
|
1577
|
+
if (typeof html !== "string") return false;
|
|
1578
|
+
const searchableHtml = html.replace(/<!--[\s\S]*?-->/g, "");
|
|
1579
|
+
if (/<html\b[^>]*\sdata-lavish-live-reload-root(?:[\s=>/]|$)[^>]*>/i.test(searchableHtml)) return true;
|
|
1580
|
+
return /<meta\b(?=[^>]*name=["']lavish-live-reload["'])(?=[^>]*content=["']root["'])[^>]*>/i.test(searchableHtml);
|
|
1581
|
+
}
|
|
1518
1582
|
function setPollActive(key, activePolls, deliveredFeedback, events, active) {
|
|
1519
1583
|
const previousPresence = computePresence(key, activePolls, deliveredFeedback);
|
|
1520
1584
|
const count = activePolls.get(key) || 0;
|
|
@@ -1758,7 +1822,7 @@ function normalizePagePath(path5) {
|
|
|
1758
1822
|
// src/cli.js
|
|
1759
1823
|
var COMMANDS = /* @__PURE__ */ new Set(["open", "poll", "end", "server", "playbook", "design"]);
|
|
1760
1824
|
var DESCRIPTION = "Lavish Editor helps agents turn rich HTML artifacts into collaborative human review surfaces. Whenever you are about to give user a complex response that will be easier to understand via a rich / interactive page, consider using Lavish Editor. First generate an interactive HTML artifact according to user request, then run `lavish-axi <html-file>` so the user can visually review it, annotate elements or selected text, queue prompts, and send feedback back through `lavish-axi poll`.";
|
|
1761
|
-
var VERSION = "0.1.
|
|
1825
|
+
var VERSION = "0.1.12";
|
|
1762
1826
|
async function run(argv) {
|
|
1763
1827
|
await ensureStateDir();
|
|
1764
1828
|
const normalizedArgv = normalizeArgv(argv);
|
|
@@ -1913,7 +1977,10 @@ async function pollCommand(args) {
|
|
|
1913
1977
|
}
|
|
1914
1978
|
const timeoutMs = flagValue(args, "--timeout-ms");
|
|
1915
1979
|
const timeoutQuery = timeoutMs ? `&timeoutMs=${encodeURIComponent(timeoutMs)}` : "";
|
|
1916
|
-
const response = await fetchJson(`${baseUrl}/api/poll?file=${encodeURIComponent(absolute)}${timeoutQuery}
|
|
1980
|
+
const response = await fetchJson(`${baseUrl}/api/poll?file=${encodeURIComponent(absolute)}${timeoutQuery}`, {
|
|
1981
|
+
retries: 3,
|
|
1982
|
+
retryDelayMs: 500
|
|
1983
|
+
});
|
|
1917
1984
|
return createPollOutput({ file: absolute, response });
|
|
1918
1985
|
}
|
|
1919
1986
|
function createPollOutput({ file, response }) {
|
|
@@ -1956,7 +2023,8 @@ async function designCommand() {
|
|
|
1956
2023
|
}
|
|
1957
2024
|
async function serverCommand(args) {
|
|
1958
2025
|
const port = Number(flagValue(args, "--port") || defaultPort());
|
|
1959
|
-
const
|
|
2026
|
+
const debug = args.includes("--verbose") || process.env.LAVISH_AXI_DEBUG === "1";
|
|
2027
|
+
const server = await serve({ port, stateFile: stateFile(), version: VERSION, debug });
|
|
1960
2028
|
await server.done;
|
|
1961
2029
|
return "";
|
|
1962
2030
|
}
|
|
@@ -1981,7 +2049,7 @@ function isHtmlPath(file) {
|
|
|
1981
2049
|
}
|
|
1982
2050
|
async function ensureServer({ forceRestart = false } = {}) {
|
|
1983
2051
|
const port = defaultPort();
|
|
1984
|
-
const baseUrl = `http
|
|
2052
|
+
const baseUrl = `http://${LOOPBACK_HOST}:${port}`;
|
|
1985
2053
|
const existing = await fetchHealth(baseUrl);
|
|
1986
2054
|
if (existing && !shouldRestartServer(VERSION, existing, forceRestart)) {
|
|
1987
2055
|
return baseUrl;
|
|
@@ -2070,36 +2138,84 @@ function killProcessOnPort(port) {
|
|
|
2070
2138
|
async function startServer(port) {
|
|
2071
2139
|
await ensureStateDir();
|
|
2072
2140
|
const entry = resolveServerEntry();
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
}
|
|
2078
|
-
|
|
2141
|
+
let logFd = null;
|
|
2142
|
+
try {
|
|
2143
|
+
logFd = openSync(serverLogFile(), "a");
|
|
2144
|
+
} catch {
|
|
2145
|
+
}
|
|
2146
|
+
try {
|
|
2147
|
+
const child = spawn(process.execPath, [entry, "server", "--port", String(port)], createServerSpawnOptions(logFd));
|
|
2148
|
+
child.unref();
|
|
2149
|
+
} finally {
|
|
2150
|
+
if (logFd !== null) closeSync(logFd);
|
|
2151
|
+
}
|
|
2079
2152
|
}
|
|
2080
2153
|
function resolveServerEntry() {
|
|
2081
2154
|
const binEntry = fileURLToPath(new URL("../bin/lavish-axi.js", import.meta.url));
|
|
2082
2155
|
if (existsSync(binEntry)) return binEntry;
|
|
2083
2156
|
return fileURLToPath(import.meta.url);
|
|
2084
2157
|
}
|
|
2085
|
-
|
|
2086
|
-
const
|
|
2158
|
+
function createServerSpawnOptions(logFd = null) {
|
|
2159
|
+
const stdio = (
|
|
2160
|
+
/** @type {import("node:child_process").StdioOptions} */
|
|
2161
|
+
logFd === null ? "ignore" : ["ignore", logFd, logFd]
|
|
2162
|
+
);
|
|
2163
|
+
return {
|
|
2164
|
+
detached: true,
|
|
2165
|
+
stdio,
|
|
2166
|
+
env: { ...process.env, LAVISH_AXI_NO_OPEN: "1" }
|
|
2167
|
+
};
|
|
2168
|
+
}
|
|
2169
|
+
async function fetchJson(url, { retries = 0, retryDelayMs = 250 } = {}) {
|
|
2170
|
+
let response;
|
|
2171
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
2172
|
+
try {
|
|
2173
|
+
response = await fetch(url);
|
|
2174
|
+
break;
|
|
2175
|
+
} catch (error) {
|
|
2176
|
+
if (error instanceof AxiError) throw error;
|
|
2177
|
+
if (attempt >= retries) throw serverConnectionError();
|
|
2178
|
+
await delay(retryDelayMs);
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
if (!response) throw serverConnectionError();
|
|
2087
2182
|
if (!response.ok) {
|
|
2088
2183
|
throw new AxiError(`Lavish Editor request failed: ${response.status}`, "SERVER_ERROR");
|
|
2089
2184
|
}
|
|
2090
|
-
|
|
2185
|
+
try {
|
|
2186
|
+
return await response.json();
|
|
2187
|
+
} catch {
|
|
2188
|
+
throw pollResponseInterruptedError();
|
|
2189
|
+
}
|
|
2091
2190
|
}
|
|
2092
2191
|
async function postJson(url, body) {
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2192
|
+
let response;
|
|
2193
|
+
try {
|
|
2194
|
+
response = await fetch(url, {
|
|
2195
|
+
method: "POST",
|
|
2196
|
+
headers: { "content-type": "application/json" },
|
|
2197
|
+
body: JSON.stringify(body)
|
|
2198
|
+
});
|
|
2199
|
+
} catch {
|
|
2200
|
+
throw serverConnectionError();
|
|
2201
|
+
}
|
|
2098
2202
|
if (!response.ok) {
|
|
2099
2203
|
throw new AxiError(`Lavish Editor request failed: ${response.status}`, "SERVER_ERROR");
|
|
2100
2204
|
}
|
|
2101
2205
|
return response.json();
|
|
2102
2206
|
}
|
|
2207
|
+
function serverConnectionError() {
|
|
2208
|
+
return new AxiError("Lavish Editor server connection failed", "SERVER_ERROR", [
|
|
2209
|
+
"Run `lavish-axi server --verbose` or inspect `~/.lavish-axi/server.log` (`LAVISH_AXI_STATE_DIR/server.log` when set) for server startup or crash diagnostics",
|
|
2210
|
+
"Re-run the last `lavish-axi poll <html-file>` command after the server is healthy"
|
|
2211
|
+
]);
|
|
2212
|
+
}
|
|
2213
|
+
function pollResponseInterruptedError() {
|
|
2214
|
+
return new AxiError("Lavish Editor poll response was interrupted", "SERVER_ERROR", [
|
|
2215
|
+
"Run `lavish-axi server --verbose` or inspect `~/.lavish-axi/server.log` (`LAVISH_AXI_STATE_DIR/server.log` when set) for server startup or crash diagnostics",
|
|
2216
|
+
"Re-run the last `lavish-axi poll <html-file>` command after the server is healthy"
|
|
2217
|
+
]);
|
|
2218
|
+
}
|
|
2103
2219
|
function flagValue(args, flag) {
|
|
2104
2220
|
const index = args.indexOf(flag);
|
|
2105
2221
|
if (index === -1) {
|
|
@@ -2152,11 +2268,11 @@ Examples:
|
|
|
2152
2268
|
`,
|
|
2153
2269
|
design: `Usage: lavish-axi design
|
|
2154
2270
|
|
|
2155
|
-
Show
|
|
2271
|
+
Show a copy-pasteable CDN snippet for Tailwind CSS browser runtime v4 + DaisyUI v5 + themes, plus technical reference for DaisyUI components. Lavish artifacts stay portable HTML. Prefer the CDN snippet over hand-writing styles unless explicitly instructed otherwise by the user. If the user asks for another design system or plain HTML, follow that request.
|
|
2156
2272
|
`,
|
|
2157
|
-
server: `Usage: lavish-axi server [--port 4387]
|
|
2273
|
+
server: `Usage: lavish-axi server [--port 4387] [--verbose]
|
|
2158
2274
|
|
|
2159
|
-
Run the local Lavish Editor server.
|
|
2275
|
+
Run the local Lavish Editor server. Pass --verbose (or set LAVISH_AXI_DEBUG=1) to log session and watcher events to stderr. Detached server output is appended to ~/.lavish-axi/server.log, or LAVISH_AXI_STATE_DIR/server.log when set, for startup and crash diagnostics.
|
|
2160
2276
|
`
|
|
2161
2277
|
};
|
|
2162
2278
|
|