lavish-axi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/dist/cli.mjs +1007 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kun Chen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<h1 align="center">lavish-axi</h1>
|
|
2
|
+
<p align="center">
|
|
3
|
+
<a href="https://github.com/kunchenguid/lavish/actions/workflows/ci.yml"
|
|
4
|
+
><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/kunchenguid/lavish/ci.yml?style=flat-square&label=ci"
|
|
5
|
+
/></a>
|
|
6
|
+
<a href="https://github.com/kunchenguid/lavish/actions/workflows/release-please.yml"
|
|
7
|
+
><img alt="Release" src="https://img.shields.io/github/actions/workflow/status/kunchenguid/lavish/release-please.yml?style=flat-square&label=release"
|
|
8
|
+
/></a>
|
|
9
|
+
<a href="https://www.npmjs.com/package/lavish-axi"
|
|
10
|
+
><img alt="npm" src="https://img.shields.io/npm/v/lavish-axi?style=flat-square"
|
|
11
|
+
/></a>
|
|
12
|
+
<a href="https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-blue?style=flat-square"
|
|
13
|
+
><img alt="Platform" src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-blue?style=flat-square"
|
|
14
|
+
/></a>
|
|
15
|
+
<a href="https://x.com/kunchenguid"
|
|
16
|
+
><img alt="X" src="https://img.shields.io/badge/X-@kunchenguid-black?style=flat-square"
|
|
17
|
+
/></a>
|
|
18
|
+
<a href="https://discord.gg/Wsy2NpnZDu"
|
|
19
|
+
><img alt="Discord" src="https://img.shields.io/discord/1439901831038763092?style=flat-square&label=discord"
|
|
20
|
+
/></a>
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
<h3 align="center">For when a rich editor is not rich enough.</h3>
|
|
24
|
+
|
|
25
|
+
HTML is the new markdown. Lavish is the new editor for your HTML artifacts.
|
|
26
|
+
|
|
27
|
+
Agents are good at producing rich HTML artifacts, but the human-agent collaboration loop on such artifacts is lacking and falls back into screenshots and long responses for “tell me what to change.”
|
|
28
|
+
That loses the thing HTML is best at: interactivity.
|
|
29
|
+
|
|
30
|
+
Lavish Editor opens an agent-generated HTML file in a local browser, lets you pinpoint elements and send feedback.
|
|
31
|
+
|
|
32
|
+
- **Browser-native review** - Serve local HTML artifacts with relative JS/CSS/assets intact.
|
|
33
|
+
- **Precise feedback** - Click elements, queue annotation & prompts , and send them back to the agent.
|
|
34
|
+
- **Agent-ergonomic interface** - Follows [AXI principles](https://axi.md), use TOON output, long polling, and contextual disclosure that keep autonomous agents on track.
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
Tell your agent:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
Use `npx lavish-axi` to write a technical plan for what we discussed.
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
**npm**
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
npm install -g lavish-axi
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**From source**
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
git clone https://github.com/kunchenguid/lavish.git
|
|
56
|
+
cd lavish
|
|
57
|
+
npm ci
|
|
58
|
+
npm run build
|
|
59
|
+
npm link
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## How It Works
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
┌───────────────┐
|
|
66
|
+
│ Agent writes │
|
|
67
|
+
│ artifact.html │
|
|
68
|
+
└───────┬───────┘
|
|
69
|
+
▼
|
|
70
|
+
┌────────────────────────┐
|
|
71
|
+
│ lavish-axi <file_path> │
|
|
72
|
+
│ opens local browser UI │
|
|
73
|
+
└───────┬────────────────┘
|
|
74
|
+
▼
|
|
75
|
+
┌────────────────────────┐
|
|
76
|
+
│ Human annotates or │
|
|
77
|
+
│ sends chat feedback │
|
|
78
|
+
└───────┬────────────────┘
|
|
79
|
+
▼
|
|
80
|
+
┌────────────────────────┐
|
|
81
|
+
│ lavish-axi poll waits │
|
|
82
|
+
│ and returns prompts │
|
|
83
|
+
└────────────────────────┘
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- **File-path identity** - Sessions are keyed by the canonical HTML file path, so agents do not need opaque IDs.
|
|
87
|
+
- **Sandboxed artifact** - The artifact runs in an iframe while Lavish injects a small SDK for annotations and snapshots.
|
|
88
|
+
- **Local-first state** - Session state stays under `.lavish-axi/` in the workspace.
|
|
89
|
+
|
|
90
|
+
## CLI Reference
|
|
91
|
+
|
|
92
|
+
| Command | Description |
|
|
93
|
+
| ----------------------------- | ------------------------------------------------------------ |
|
|
94
|
+
| `lavish-axi` | Show current sessions and usage guidance. |
|
|
95
|
+
| `lavish-axi <html-file>` | Open or resume a Lavish Editor session. |
|
|
96
|
+
| `lavish-axi poll <html-file>` | Long-poll until the user sends feedback or ends the session. |
|
|
97
|
+
| `lavish-axi end <html-file>` | End a session. |
|
|
98
|
+
|
|
99
|
+
### Flags
|
|
100
|
+
|
|
101
|
+
| Command | Flag | Description |
|
|
102
|
+
| ------------------------ | --------------------- | ------------------------------------------------------------------------- |
|
|
103
|
+
| `lavish-axi <html-file>` | `--no-open` | Ensure the server/session exists without opening another browser window. |
|
|
104
|
+
| `lavish-axi poll` | `--agent-reply "..."` | Show the agent's reply in the existing browser chat before polling again. |
|
|
105
|
+
| `lavish-axi poll` | `--timeout-ms <ms>` | Test/debug escape hatch only; agents should normally omit it. |
|
|
106
|
+
|
|
107
|
+
## Development
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
npm run build # Bundle the publishable CLI
|
|
111
|
+
npm test # Run node:test tests
|
|
112
|
+
npm run lint # Run ESLint
|
|
113
|
+
npm run format:check # Check Prettier formatting
|
|
114
|
+
npm run typecheck # Run TypeScript checkJs validation
|
|
115
|
+
```
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,1007 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.js
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { access } from "node:fs/promises";
|
|
6
|
+
import os2 from "node:os";
|
|
7
|
+
import path4 from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { AxiError, runAxiCli } from "axi-sdk-js";
|
|
10
|
+
|
|
11
|
+
// src/paths.js
|
|
12
|
+
import { mkdir } from "node:fs/promises";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
function stateDir() {
|
|
16
|
+
return process.env.LAVISH_AXI_STATE_DIR || path.join(os.homedir(), ".lavish-axi");
|
|
17
|
+
}
|
|
18
|
+
function stateFile() {
|
|
19
|
+
return path.join(stateDir(), "state.json");
|
|
20
|
+
}
|
|
21
|
+
async function ensureStateDir() {
|
|
22
|
+
await mkdir(stateDir(), { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
function defaultPort() {
|
|
25
|
+
return Number(process.env.LAVISH_AXI_PORT || 4387);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// src/server.js
|
|
29
|
+
import { EventEmitter } from "node:events";
|
|
30
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
31
|
+
import path3 from "node:path";
|
|
32
|
+
import chokidar from "chokidar";
|
|
33
|
+
import express from "express";
|
|
34
|
+
|
|
35
|
+
// src/html-transform.js
|
|
36
|
+
function injectLavishSdk(html, key) {
|
|
37
|
+
const script = `<script src="/sdk.js?key=${encodeURIComponent(key)}"></script>`;
|
|
38
|
+
if (/<\/body\s*>/i.test(html)) {
|
|
39
|
+
return html.replace(/<\/body\s*>/i, `${script}</body>`);
|
|
40
|
+
}
|
|
41
|
+
return `${html}
|
|
42
|
+
${script}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/session-store.js
|
|
46
|
+
import crypto from "node:crypto";
|
|
47
|
+
import { readFile, realpath, writeFile } from "node:fs/promises";
|
|
48
|
+
import path2 from "node:path";
|
|
49
|
+
var SessionStore = class {
|
|
50
|
+
constructor(file) {
|
|
51
|
+
this.file = file;
|
|
52
|
+
}
|
|
53
|
+
async listSessions() {
|
|
54
|
+
const state = await this.readState();
|
|
55
|
+
return Object.values(state.sessions).sort((a, b) => a.file.localeCompare(b.file));
|
|
56
|
+
}
|
|
57
|
+
async findByFile(file) {
|
|
58
|
+
const absolute = await canonicalFile(file);
|
|
59
|
+
const state = await this.readState();
|
|
60
|
+
return state.sessions[sessionKey(absolute)] || null;
|
|
61
|
+
}
|
|
62
|
+
async findByKey(key) {
|
|
63
|
+
const state = await this.readState();
|
|
64
|
+
return state.sessions[key] || null;
|
|
65
|
+
}
|
|
66
|
+
async upsertSession(file, url) {
|
|
67
|
+
const absolute = await canonicalFile(file);
|
|
68
|
+
const key = sessionKey(absolute);
|
|
69
|
+
const state = await this.readState();
|
|
70
|
+
const existing = state.sessions[key] || {};
|
|
71
|
+
const session = {
|
|
72
|
+
key,
|
|
73
|
+
file: absolute,
|
|
74
|
+
url,
|
|
75
|
+
status: existing.status === "ended" ? "open" : existing.status || "open",
|
|
76
|
+
pending_prompts: existing.pending_prompts || 0,
|
|
77
|
+
prompts: existing.prompts || [],
|
|
78
|
+
dom_snapshot: existing.dom_snapshot || "",
|
|
79
|
+
chat: existing.chat || [],
|
|
80
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
81
|
+
};
|
|
82
|
+
state.sessions[key] = session;
|
|
83
|
+
await this.writeState(state);
|
|
84
|
+
return session;
|
|
85
|
+
}
|
|
86
|
+
async queuePrompts(key, payload) {
|
|
87
|
+
const state = await this.readState();
|
|
88
|
+
const session = state.sessions[key];
|
|
89
|
+
if (!session) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const prompts = Array.isArray(payload.prompts) ? payload.prompts : [];
|
|
93
|
+
const normalizedPrompts = prompts.map(normalizePrompt);
|
|
94
|
+
const userMessages = normalizedPrompts.filter((prompt) => prompt.tag === "message" && prompt.prompt).map((prompt) => ({ role: "user", text: prompt.prompt, at: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
95
|
+
session.prompts = [...session.prompts || [], ...normalizedPrompts];
|
|
96
|
+
session.chat = [...session.chat || [], ...userMessages];
|
|
97
|
+
session.pending_prompts = session.prompts.length;
|
|
98
|
+
session.dom_snapshot = String(payload.domSnapshot || payload.dom_snapshot || "");
|
|
99
|
+
session.status = "feedback";
|
|
100
|
+
session.updated_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
101
|
+
await this.writeState(state);
|
|
102
|
+
return session;
|
|
103
|
+
}
|
|
104
|
+
async takeFeedback(key) {
|
|
105
|
+
const state = await this.readState();
|
|
106
|
+
const session = state.sessions[key];
|
|
107
|
+
if (!session) {
|
|
108
|
+
return { status: "missing" };
|
|
109
|
+
}
|
|
110
|
+
if (session.status === "ended") {
|
|
111
|
+
return { status: "ended" };
|
|
112
|
+
}
|
|
113
|
+
const prompts = session.prompts || [];
|
|
114
|
+
if (prompts.length === 0) {
|
|
115
|
+
return { status: "waiting" };
|
|
116
|
+
}
|
|
117
|
+
const result = {
|
|
118
|
+
status: "feedback",
|
|
119
|
+
dom_snapshot: session.dom_snapshot || "",
|
|
120
|
+
prompts
|
|
121
|
+
};
|
|
122
|
+
session.prompts = [];
|
|
123
|
+
session.pending_prompts = 0;
|
|
124
|
+
session.dom_snapshot = "";
|
|
125
|
+
session.status = "open";
|
|
126
|
+
session.updated_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
127
|
+
await this.writeState(state);
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
async endSession(key) {
|
|
131
|
+
const state = await this.readState();
|
|
132
|
+
const session = state.sessions[key];
|
|
133
|
+
if (!session) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
session.status = "ended";
|
|
137
|
+
session.updated_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
138
|
+
await this.writeState(state);
|
|
139
|
+
return session;
|
|
140
|
+
}
|
|
141
|
+
async addAgentReply(key, text) {
|
|
142
|
+
const state = await this.readState();
|
|
143
|
+
const session = state.sessions[key];
|
|
144
|
+
if (!session) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
session.chat = [...session.chat || [], { role: "agent", text: String(text || ""), at: (/* @__PURE__ */ new Date()).toISOString() }];
|
|
148
|
+
session.updated_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
149
|
+
await this.writeState(state);
|
|
150
|
+
return session;
|
|
151
|
+
}
|
|
152
|
+
async readState() {
|
|
153
|
+
try {
|
|
154
|
+
const raw = await readFile(this.file, "utf8");
|
|
155
|
+
const parsed = JSON.parse(raw);
|
|
156
|
+
return { sessions: parsed.sessions || {} };
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error && error.code === "ENOENT") {
|
|
159
|
+
return { sessions: {} };
|
|
160
|
+
}
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async writeState(state) {
|
|
165
|
+
await writeFile(this.file, `${JSON.stringify(state, null, 2)}
|
|
166
|
+
`);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
async function canonicalFile(file) {
|
|
170
|
+
const absolute = path2.resolve(file);
|
|
171
|
+
return realpath(absolute);
|
|
172
|
+
}
|
|
173
|
+
function sessionKey(file) {
|
|
174
|
+
return crypto.createHash("sha256").update(file).digest("hex").slice(0, 16);
|
|
175
|
+
}
|
|
176
|
+
function normalizePrompt(prompt) {
|
|
177
|
+
return {
|
|
178
|
+
uid: String(prompt.uid || ""),
|
|
179
|
+
prompt: String(prompt.prompt || ""),
|
|
180
|
+
selector: String(prompt.selector || ""),
|
|
181
|
+
tag: String(prompt.tag || ""),
|
|
182
|
+
text: String(prompt.text || "")
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// src/server.js
|
|
187
|
+
async function serve({ port, stateFile: stateFile2 }) {
|
|
188
|
+
const app = express();
|
|
189
|
+
const store = new SessionStore(stateFile2);
|
|
190
|
+
const events = new EventEmitter();
|
|
191
|
+
const watchers = /* @__PURE__ */ new Map();
|
|
192
|
+
const activePolls = /* @__PURE__ */ new Map();
|
|
193
|
+
app.use(express.json({ limit: "2mb" }));
|
|
194
|
+
app.get("/health", (req, res) => {
|
|
195
|
+
res.json({ ok: true });
|
|
196
|
+
});
|
|
197
|
+
app.post("/api/sessions", async (req, res, next) => {
|
|
198
|
+
try {
|
|
199
|
+
const file = await canonicalFile(req.body.file);
|
|
200
|
+
const key = sessionKey(file);
|
|
201
|
+
const url = `http://localhost:${port}/session/${key}`;
|
|
202
|
+
const session = await store.upsertSession(file, url);
|
|
203
|
+
watchSession(session, watchers, events);
|
|
204
|
+
res.json({ key, file, url, status: "opened" });
|
|
205
|
+
} catch (error) {
|
|
206
|
+
next(error);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
app.get("/api/poll", async (req, res, next) => {
|
|
210
|
+
try {
|
|
211
|
+
const file = await canonicalFile(String(req.query.file || ""));
|
|
212
|
+
const key = sessionKey(file);
|
|
213
|
+
const timeoutMs = req.query.timeoutMs === void 0 ? null : Math.max(0, Math.min(Number(req.query.timeoutMs || 0), 2147483647));
|
|
214
|
+
const immediate = await store.takeFeedback(key);
|
|
215
|
+
if (immediate.status !== "waiting") {
|
|
216
|
+
res.json(immediate);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
setPollActive(key, activePolls, events, true);
|
|
220
|
+
const timer = timeoutMs === null ? null : setTimeout(async () => {
|
|
221
|
+
cleanup();
|
|
222
|
+
res.json(await store.takeFeedback(key));
|
|
223
|
+
}, timeoutMs);
|
|
224
|
+
const onFeedback = async (changedKey) => {
|
|
225
|
+
if (changedKey !== key || res.headersSent) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
cleanup();
|
|
229
|
+
res.json(await store.takeFeedback(key));
|
|
230
|
+
};
|
|
231
|
+
let cleaned = false;
|
|
232
|
+
const cleanup = () => {
|
|
233
|
+
if (cleaned) return;
|
|
234
|
+
cleaned = true;
|
|
235
|
+
if (timer) clearTimeout(timer);
|
|
236
|
+
events.off("feedback", onFeedback);
|
|
237
|
+
events.off("ended", onFeedback);
|
|
238
|
+
setPollActive(key, activePolls, events, false);
|
|
239
|
+
};
|
|
240
|
+
events.on("feedback", onFeedback);
|
|
241
|
+
events.on("ended", onFeedback);
|
|
242
|
+
req.on("close", cleanup);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
next(error);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
app.post("/api/:key/prompts", async (req, res, next) => {
|
|
248
|
+
try {
|
|
249
|
+
const session = await store.queuePrompts(req.params.key, req.body || {});
|
|
250
|
+
if (!session) {
|
|
251
|
+
res.status(404).json({ error: "session not found" });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
events.emit("feedback", req.params.key);
|
|
255
|
+
res.json({ status: "queued", pending_prompts: session.pending_prompts });
|
|
256
|
+
} catch (error) {
|
|
257
|
+
next(error);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
app.post("/api/:key/end", async (req, res, next) => {
|
|
261
|
+
try {
|
|
262
|
+
await store.endSession(req.params.key);
|
|
263
|
+
events.emit("ended", req.params.key);
|
|
264
|
+
res.json({ status: "ended" });
|
|
265
|
+
} catch (error) {
|
|
266
|
+
next(error);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
app.post("/api/:key/agent-reply", async (req, res, next) => {
|
|
270
|
+
try {
|
|
271
|
+
const text = String(req.body?.text || "");
|
|
272
|
+
const session = await store.addAgentReply(req.params.key, text);
|
|
273
|
+
if (!session) {
|
|
274
|
+
res.status(404).json({ error: "session not found" });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
events.emit("agent-reply", req.params.key, text);
|
|
278
|
+
res.json({ status: "sent" });
|
|
279
|
+
} catch (error) {
|
|
280
|
+
next(error);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
app.post("/api/end", async (req, res, next) => {
|
|
284
|
+
try {
|
|
285
|
+
const file = await canonicalFile(req.body.file);
|
|
286
|
+
const key = sessionKey(file);
|
|
287
|
+
await store.endSession(key);
|
|
288
|
+
events.emit("ended", key);
|
|
289
|
+
res.json({ status: "ended" });
|
|
290
|
+
} catch (error) {
|
|
291
|
+
next(error);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
app.get("/session/:key", async (req, res, next) => {
|
|
295
|
+
try {
|
|
296
|
+
const session = await store.findByKey(req.params.key);
|
|
297
|
+
if (!session) {
|
|
298
|
+
res.status(404).send("Session not found");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
watchSession(session, watchers, events);
|
|
302
|
+
res.type("html").send(createChromeHtml(session));
|
|
303
|
+
} catch (error) {
|
|
304
|
+
next(error);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
app.get("/artifact/:key", (req, res) => {
|
|
308
|
+
res.redirect(`/artifact/${req.params.key}/index.html`);
|
|
309
|
+
});
|
|
310
|
+
app.get(/^\/artifact\/([^/]+)\/index\.html$/, async (req, res, next) => {
|
|
311
|
+
try {
|
|
312
|
+
const key = req.params[0];
|
|
313
|
+
const session = await store.findByKey(key);
|
|
314
|
+
if (!session) {
|
|
315
|
+
res.status(404).send("Session not found");
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const html = await readFile2(session.file, "utf8");
|
|
319
|
+
res.type("html").send(injectLavishSdk(html, key));
|
|
320
|
+
} catch (error) {
|
|
321
|
+
next(error);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
app.get(/^\/artifact\/([^/]+)\/(.+)$/, async (req, res, next) => {
|
|
325
|
+
try {
|
|
326
|
+
const key = req.params[0];
|
|
327
|
+
const assetPath = req.params[1];
|
|
328
|
+
const session = await store.findByKey(key);
|
|
329
|
+
if (!session) {
|
|
330
|
+
res.status(404).send("Session not found");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const root = path3.dirname(session.file);
|
|
334
|
+
const file = resolveArtifactAsset(root, assetPath);
|
|
335
|
+
if (!file) {
|
|
336
|
+
res.status(403).send("Forbidden");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
res.sendFile(file);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
next(error);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
app.get("/events/:key", async (req, res, next) => {
|
|
345
|
+
try {
|
|
346
|
+
res.writeHead(200, {
|
|
347
|
+
"content-type": "text/event-stream",
|
|
348
|
+
"cache-control": "no-cache",
|
|
349
|
+
connection: "keep-alive"
|
|
350
|
+
});
|
|
351
|
+
const session = await store.findByKey(req.params.key);
|
|
352
|
+
const sendReload = (key) => {
|
|
353
|
+
if (key === req.params.key) {
|
|
354
|
+
res.write("event: reload\ndata: {}\n\n");
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
const sendAgentReply = (key, text) => {
|
|
358
|
+
if (key === req.params.key) {
|
|
359
|
+
res.write(`event: agent-reply
|
|
360
|
+
data: ${JSON.stringify({ text })}
|
|
361
|
+
|
|
362
|
+
`);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
const sendWorking = (key, working) => {
|
|
366
|
+
if (key === req.params.key) {
|
|
367
|
+
res.write(`event: agent-working
|
|
368
|
+
data: ${JSON.stringify({ working })}
|
|
369
|
+
|
|
370
|
+
`);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
res.write(`event: chat-sync
|
|
374
|
+
data: ${JSON.stringify({ chat: session?.chat || [] })}
|
|
375
|
+
|
|
376
|
+
`);
|
|
377
|
+
res.write(`event: agent-working
|
|
378
|
+
data: ${JSON.stringify({ working: !activePolls.has(req.params.key) })}
|
|
379
|
+
|
|
380
|
+
`);
|
|
381
|
+
events.on("reload", sendReload);
|
|
382
|
+
events.on("agent-reply", sendAgentReply);
|
|
383
|
+
events.on("agent-working", sendWorking);
|
|
384
|
+
req.on("close", () => {
|
|
385
|
+
events.off("reload", sendReload);
|
|
386
|
+
events.off("agent-reply", sendAgentReply);
|
|
387
|
+
events.off("agent-working", sendWorking);
|
|
388
|
+
});
|
|
389
|
+
} catch (error) {
|
|
390
|
+
next(error);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
app.get("/sdk.js", (req, res) => {
|
|
394
|
+
res.type("application/javascript").send(createSdkJs(String(req.query.key || "")));
|
|
395
|
+
});
|
|
396
|
+
app.use((error, req, res, _next) => {
|
|
397
|
+
res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
|
|
398
|
+
});
|
|
399
|
+
await new Promise((resolve) => {
|
|
400
|
+
app.listen(port, "127.0.0.1", resolve);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
function resolveArtifactAsset(root, assetPath) {
|
|
404
|
+
const file = path3.resolve(root, assetPath);
|
|
405
|
+
const relative = path3.relative(root, file);
|
|
406
|
+
if (relative.startsWith("..") || path3.isAbsolute(relative)) {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
return file;
|
|
410
|
+
}
|
|
411
|
+
function watchSession(session, watchers, events) {
|
|
412
|
+
if (watchers.has(session.key)) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const root = path3.dirname(session.file);
|
|
416
|
+
const watcher = chokidar.watch(root, {
|
|
417
|
+
ignored: /(^|[/\\])(\.git|node_modules|dist|build|\.lavish-axi)([/\\]|$)/,
|
|
418
|
+
ignoreInitial: true,
|
|
419
|
+
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }
|
|
420
|
+
});
|
|
421
|
+
let timer = null;
|
|
422
|
+
watcher.on("all", () => {
|
|
423
|
+
clearTimeout(timer);
|
|
424
|
+
timer = setTimeout(() => events.emit("reload", session.key), 100);
|
|
425
|
+
});
|
|
426
|
+
watchers.set(session.key, watcher);
|
|
427
|
+
}
|
|
428
|
+
function setPollActive(key, activePolls, events, active) {
|
|
429
|
+
const count = activePolls.get(key) || 0;
|
|
430
|
+
const nextCount = active ? count + 1 : Math.max(0, count - 1);
|
|
431
|
+
if (nextCount === count) return;
|
|
432
|
+
if (nextCount === 0) {
|
|
433
|
+
activePolls.delete(key);
|
|
434
|
+
} else {
|
|
435
|
+
activePolls.set(key, nextCount);
|
|
436
|
+
}
|
|
437
|
+
if (count > 0 === nextCount > 0) return;
|
|
438
|
+
events.emit("agent-working", key, nextCount === 0);
|
|
439
|
+
}
|
|
440
|
+
function createChromeHtml(session) {
|
|
441
|
+
const initialChat = JSON.stringify(session.chat || []);
|
|
442
|
+
return `<!doctype html>
|
|
443
|
+
<html>
|
|
444
|
+
<head>
|
|
445
|
+
<meta charset="utf-8">
|
|
446
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
447
|
+
<title>Lavish Editor</title>
|
|
448
|
+
<style>
|
|
449
|
+
body{margin:0;background:#0f1115;color:#f7f3ea;font:14px/1.4 ui-sans-serif,system-ui,sans-serif;overflow:hidden}
|
|
450
|
+
.bar{height:56px;display:flex;align-items:center;gap:12px;padding:0 16px;background:#171a21;border-bottom:1px solid #2a2f3a;box-sizing:border-box}
|
|
451
|
+
.brand{font-weight:750;letter-spacing:.02em}.file{color:#b9c0cf;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
|
|
452
|
+
button{border:0;border-radius:10px;padding:9px 12px;background:#f4c95d;color:#17130a;font-weight:700;cursor:pointer}button:disabled{opacity:.55;cursor:not-allowed}
|
|
453
|
+
button.secondary{background:#2a2f3a;color:#f7f3ea}button.danger{background:#f06464;color:white}
|
|
454
|
+
.layout{height:calc(100vh - 56px);min-height:0;display:grid;grid-template-columns:1fr 360px}.frame{background:white}.panel{border-left:1px solid #2a2f3a;background:#11141a;display:flex;flex-direction:column;min-width:0;min-height:0}
|
|
455
|
+
.panel h2{font-size:15px;margin:16px 16px 8px}.chat{flex:1;min-height:0;overflow:auto;padding:0 16px 12px;display:flex;flex-direction:column;gap:10px}.bubble{border-radius:14px;padding:10px 12px;background:#1c212b;border:1px solid #303745}.bubble.user{background:#25230f;border-color:#5d4d1b}.bubble.agent{background:#172419;border-color:#315f3a}.bubble.agent-working{display:flex;align-items:center;gap:8px;color:#d8deea}.spinner{width:14px;height:14px;border-radius:999px;border:2px solid #315f3a;border-top-color:#8fe39e;animation:spin .8s linear infinite}.bubble small{display:block;color:#aeb6c6;margin-bottom:4px}@keyframes spin{to{transform:rotate(360deg)}}.composer{display:grid;gap:8px;padding:12px 16px;border-top:1px solid #2a2f3a;min-width:0;flex-shrink:0;box-sizing:border-box}.annotation-pills{display:flex;flex-wrap:wrap;gap:6px;min-width:0}.pill-wrap{position:relative;max-width:100%}.pill{display:flex;align-items:center;gap:6px;max-width:100%;border:1px solid #3c4557;border-radius:999px;background:#1c212b;color:#d8deea;padding:5px 7px 5px 9px;font-size:12px;font-weight:750}.pill-preview{display:block;max-width:220px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.pill-close{width:18px;height:18px;border:0;border-radius:999px;padding:0;background:#303745;color:#d8deea;line-height:18px;font-size:14px}.pill-tooltip{display:none;position:absolute;z-index:5;left:0;bottom:calc(100% + 8px);width:min(320px,80vw);border:1px solid #3c4557;border-radius:12px;background:#171a21;color:#d8deea;padding:10px;font-size:12px;font-weight:500;box-shadow:0 16px 44px rgba(0,0,0,.35)}.tooltip-label{color:#8c96aa;font-size:10px;font-weight:900;letter-spacing:.08em;text-transform:uppercase;margin:0 0 4px}.pill-tooltip-target{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#0f1115;border:1px solid #303745;border-radius:8px;padding:6px;margin-bottom:8px;overflow-wrap:anywhere}.pill-tooltip-prompt{white-space:pre-wrap;overflow-wrap:anywhere}.pill-wrap:hover .pill-tooltip{display:block}.composer textarea{width:100%;max-width:100%;min-width:0;min-height:82px;resize:vertical;border-radius:12px;border:1px solid #303745;background:#0f1115;color:#f7f3ea;padding:10px;font:inherit;box-sizing:border-box}.actions{display:flex;gap:8px}
|
|
456
|
+
iframe{width:100%;height:100%;border:0;background:white}
|
|
457
|
+
</style>
|
|
458
|
+
</head>
|
|
459
|
+
<body>
|
|
460
|
+
<div class="bar"><div class="brand">Lavish Editor</div><div class="file">${escapeHtml(session.file)}</div><button id="annotation">Annotation: On</button><button class="danger" id="end">End Session</button></div>
|
|
461
|
+
<div class="layout"><div class="frame"><iframe id="artifact" sandbox="allow-scripts allow-forms allow-popups allow-downloads" src="/artifact/${session.key}/index.html"></iframe></div><aside class="panel"><h2>Conversation</h2><div class="chat" id="chatLog"></div><div class="composer"><div class="annotation-pills" id="annotationPills"></div><textarea id="chatInput" placeholder="Write a message for the agent..."></textarea><div class="actions"><button id="send">Send to Agent</button></div></div></aside></div>
|
|
462
|
+
<script>
|
|
463
|
+
const key=${JSON.stringify(session.key)};
|
|
464
|
+
const initialChat=${initialChat};
|
|
465
|
+
const frame=document.getElementById('artifact');
|
|
466
|
+
const annotationPills=document.getElementById('annotationPills');
|
|
467
|
+
const chatLog=document.getElementById('chatLog');
|
|
468
|
+
const chatInput=document.getElementById('chatInput');
|
|
469
|
+
const sendButton=document.getElementById('send');
|
|
470
|
+
const queued=[];
|
|
471
|
+
let annotation=true;
|
|
472
|
+
let agentPolling=false;
|
|
473
|
+
let pendingSnapshot='';
|
|
474
|
+
let workingBubble=null;
|
|
475
|
+
function render(){annotationPills.innerHTML=queued.map((p,i)=>'<div class="pill-wrap"><div class="pill"><span class="pill-preview">'+escapeHtml(p.prompt)+'</span><button class="pill-close" type="button" aria-label="Remove queued prompt" onclick="removeQueuedPrompt('+i+',event)">\xD7</button></div><div class="pill-tooltip">'+(p.selector?'<div class="tooltip-label">Target</div><div class="pill-tooltip-target">'+escapeHtml(p.selector)+'</div>':'')+'<div class="tooltip-label">Prompt</div><div class="pill-tooltip-prompt">'+escapeHtml(p.prompt)+'</div></div></div>').join('')}
|
|
476
|
+
function escapeHtml(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
|
477
|
+
function addChat(role,text){if(!text)return;const el=document.createElement('div');el.className='bubble '+role;el.innerHTML='<small>'+(role==='agent'?'Agent':'You')+'</small><div>'+escapeHtml(text)+'</div>';chatLog.appendChild(el);chatLog.scrollTop=chatLog.scrollHeight}
|
|
478
|
+
function syncChat(chat){for(const el of [...chatLog.querySelectorAll('.bubble.user,.bubble.agent:not(.agent-working)')])el.remove();for(const item of chat)addChat(item.role,item.text);if(workingBubble)chatLog.appendChild(workingBubble);chatLog.scrollTop=chatLog.scrollHeight}
|
|
479
|
+
function setAgentPolling(active){agentPolling=!!active;sendButton.disabled=!agentPolling;if(agentPolling){if(workingBubble)workingBubble.remove();workingBubble=null;return}if(!workingBubble){workingBubble=document.createElement('div');workingBubble.className='bubble agent agent-working';workingBubble.innerHTML='<span class="spinner"></span><span>Working...</span>';chatLog.appendChild(workingBubble)}chatLog.scrollTop=chatLog.scrollHeight}
|
|
480
|
+
function removeQueuedPrompt(index,event){if(event)event.stopPropagation();queued.splice(index,1);render()}
|
|
481
|
+
function postToFrame(message){frame.contentWindow&&frame.contentWindow.postMessage(message,'*')}
|
|
482
|
+
window.addEventListener('message',event=>{if(event.source!==frame.contentWindow)return;const msg=event.data||{};if(msg.type==='lavish:queuePrompt'){queued.push(msg.prompt);render()}if(msg.type==='lavish:snapshot'){pendingSnapshot=msg.snapshot||'';submitQueued()}if(msg.type==='lavish:sendQueuedPrompts'){sendQueued()}if(msg.type==='lavish:endSession'){endSession()}});
|
|
483
|
+
document.getElementById('annotation').onclick=()=>{annotation=!annotation;document.getElementById('annotation').textContent='Annotation: '+(annotation?'On':'Off');postToFrame({type:'lavish:setAnnotationMode',enabled:annotation})};
|
|
484
|
+
sendButton.onclick=sendQueued;document.getElementById('end').onclick=endSession;
|
|
485
|
+
frame.addEventListener('load',()=>postToFrame({type:'lavish:setAnnotationMode',enabled:annotation}));
|
|
486
|
+
function sendQueued(){if(!agentPolling)return;const text=chatInput.value.trim();if(text){queued.push({uid:'',prompt:text,selector:'',tag:'message',text:'Freeform message'});addChat('user',text);chatInput.value='';render()}if(!queued.length)return;postToFrame({type:'lavish:requestSnapshot'})}
|
|
487
|
+
async function submitQueued(){const prompts=queued.splice(0,queued.length);render();setAgentPolling(false);await fetch('/api/'+key+'/prompts',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({prompts,domSnapshot:pendingSnapshot})})}
|
|
488
|
+
async function endSession(){await fetch('/api/'+key+'/end',{method:'POST'});document.body.innerHTML='<div class="bar"><div class="brand">Lavish Editor</div><div class="file">Session ended. The agent polling loop can stop.</div></div>'}
|
|
489
|
+
const events=new EventSource('/events/'+key);events.addEventListener('reload',()=>{frame.src=frame.src});events.addEventListener('agent-reply',event=>addChat('agent',JSON.parse(event.data).text));events.addEventListener('chat-sync',event=>syncChat(JSON.parse(event.data).chat||[]));events.addEventListener('agent-working',event=>setAgentPolling(!JSON.parse(event.data).working));render();initialChat.forEach(item=>addChat(item.role,item.text));setAgentPolling(false);
|
|
490
|
+
</script>
|
|
491
|
+
</body>
|
|
492
|
+
</html>`;
|
|
493
|
+
}
|
|
494
|
+
function createSdkJs(key) {
|
|
495
|
+
return `(() => {
|
|
496
|
+
const key=${JSON.stringify(key)};
|
|
497
|
+
let annotationMode=true;
|
|
498
|
+
let hovered=null;
|
|
499
|
+
let selected=null;
|
|
500
|
+
let host=null;
|
|
501
|
+
let shadow=null;
|
|
502
|
+
let counter=0;
|
|
503
|
+
const ids=new WeakMap();
|
|
504
|
+
function uid(el){if(!ids.has(el))ids.set(el,String(++counter));return ids.get(el)}
|
|
505
|
+
function selector(el){if(!el||!el.tagName)return'';const parts=[];let node=el;while(node&&node.nodeType===1&&parts.length<5){let part=node.tagName.toLowerCase();if(node.id){part+='#'+CSS.escape(node.id);parts.unshift(part);break}const parent=node.parentElement;if(parent){const same=[...parent.children].filter(x=>x.tagName===node.tagName);if(same.length>1)part+=':nth-of-type('+(same.indexOf(node)+1)+')'}parts.unshift(part);node=parent}return parts.join(' > ')}
|
|
506
|
+
function context(el){return{uid:uid(el),selector:selector(el),tag:(el.tagName||'').toLowerCase(),text:(el.innerText||el.textContent||'').trim().replace(/\\s+/g,' ').slice(0,240)}}
|
|
507
|
+
function isLavishUi(el){return !!(el&&el.closest&&el.closest('[data-lavish-ui]'))}
|
|
508
|
+
function highlightElement(el){if(el){el.style.outline='2px solid #f4c95d';el.style.outlineOffset='2px'}}
|
|
509
|
+
function clearHighlight(el){if(el)el.style.outline=''}
|
|
510
|
+
function setAnnotationMode(enabled){annotationMode=!!enabled;let style=document.getElementById('lavish-cursor-style');if(annotationMode&&!style){style=document.createElement('style');style.id='lavish-cursor-style';style.textContent='*{cursor:default!important}';document.head.appendChild(style)}if(!annotationMode&&style)style.remove();if(!annotationMode)closeCard()}
|
|
511
|
+
function queuePrompt(prompt,options={}){const item={...context(options.element||document.activeElement||document.body),prompt:String(prompt||'')};if(options.uid)item.uid=String(options.uid);if(options.selector)item.selector=String(options.selector);if(options.tag)item.tag=String(options.tag);if(options.text)item.text=String(options.text);if(options.data)item.prompt+='\\n\\nContext data:\\n'+JSON.stringify(options.data,null,2);parent.postMessage({type:'lavish:queuePrompt',prompt:item},'*')}
|
|
512
|
+
function sendQueuedPrompts(){parent.postMessage({type:'lavish:sendQueuedPrompts'},'*')}
|
|
513
|
+
function endSession(){parent.postMessage({type:'lavish:endSession'},'*')}
|
|
514
|
+
function snapshot(){const lines=[];function walk(el,depth){if(!(el instanceof Element)||depth>6||isLavishUi(el))return;const c=context(el);const name=c.text?' "'+c.text.slice(0,80).replace(/"/g,"'")+'"':'';lines.push(' '.repeat(depth)+'uid='+c.uid+' '+c.tag+name);for(const child of el.children)walk(child,depth+1)}walk(document.body,0);return lines.join('\\n')}
|
|
515
|
+
function ensureShadow(){if(shadow)return shadow;host=document.createElement('div');host.className='lavish-annotation-root';host.setAttribute('data-lavish-ui','annotation-root');document.documentElement.appendChild(host);shadow=host.attachShadow({mode:'open'});const style=document.createElement('style');style.textContent=':host{all:initial;position:fixed;z-index:2147483647;left:0;top:0;color-scheme:dark;font-family:ui-sans-serif,system-ui,sans-serif}*{box-sizing:border-box}.lavish-annotation-card{position:fixed;width:min(320px,calc(100vw - 24px));padding:12px;border-radius:14px;background:#11141a;color:#f7f3ea;border:1px solid #f4c95d;box-shadow:0 20px 70px rgba(0,0,0,.35);font:14px/1.4 ui-sans-serif,system-ui,sans-serif}.lavish-annotation-card textarea{width:100%;min-height:86px;resize:vertical;border-radius:10px;border:1px solid #303745;background:#0f1115;color:#f7f3ea;padding:9px;font:inherit}.lavish-annotation-card .lavish-row{display:flex;gap:8px;justify-content:flex-end;margin-top:8px}.lavish-annotation-card button{border:0;border-radius:9px;padding:8px 10px;font-weight:800;cursor:pointer}.lavish-annotation-card .lavish-send{background:#f4c95d;color:#17130a}.lavish-annotation-card .lavish-cancel{background:#2a2f3a;color:#f7f3ea}';shadow.appendChild(style);return shadow}
|
|
516
|
+
function closeCard(){if(shadow){for(const el of [...shadow.querySelectorAll('.lavish-annotation-card')])el.remove()}clearHighlight(selected);selected=null}
|
|
517
|
+
function showAnnotationCard(target){const root=ensureShadow();closeCard();selected=target;highlightElement(selected);const c=context(target);const rect=target.getBoundingClientRect();const card=document.createElement('div');card.className='lavish-annotation-card';card.innerHTML='<div style="font-weight:800;margin-bottom:6px">Annotate <'+c.tag+'></div><textarea placeholder="Tell the agent what to change about this element..."></textarea><div class="lavish-row"><button class="lavish-cancel" type="button">Cancel</button><button class="lavish-send" type="button">Queue Prompt</button></div>';root.appendChild(card);const left=Math.min(Math.max(12,rect.left),window.innerWidth-card.offsetWidth-12);const top=Math.min(Math.max(12,rect.bottom+8),window.innerHeight-card.offsetHeight-12);card.style.left=left+'px';card.style.top=top+'px';const textarea=card.querySelector('textarea');card.querySelector('.lavish-cancel').onclick=closeCard;card.querySelector('.lavish-send').onclick=()=>{const prompt=textarea.value.trim();if(prompt)queuePrompt(prompt,c);closeCard()};setTimeout(()=>textarea.focus(),0)}
|
|
518
|
+
window.lavish={queuePrompt,sendQueuedPrompts,endSession,getQueuedPrompts:()=>[],setStatus:message=>parent.postMessage({type:'lavish:status',message:String(message)},'*'),snapshot};
|
|
519
|
+
window.addEventListener('message',event=>{const msg=event.data||{};if(msg.type==='lavish:setAnnotationMode')setAnnotationMode(msg.enabled);if(msg.type==='lavish:requestSnapshot')parent.postMessage({type:'lavish:snapshot',snapshot:snapshot()},'*')});
|
|
520
|
+
document.addEventListener('mouseover',event=>{if(!annotationMode||isLavishUi(event.target))return;if(event.target===selected)return;if(hovered&&hovered!==selected)clearHighlight(hovered);hovered=event.target;highlightElement(hovered)},true);
|
|
521
|
+
document.addEventListener('mouseout',()=>{if(hovered&&hovered!==selected){clearHighlight(hovered);hovered=null}},true);
|
|
522
|
+
document.addEventListener('click',event=>{if(!annotationMode||isLavishUi(event.target))return;event.preventDefault();event.stopPropagation();showAnnotationCard(event.target)},true);
|
|
523
|
+
setAnnotationMode(annotationMode);
|
|
524
|
+
})();`;
|
|
525
|
+
}
|
|
526
|
+
function escapeHtml(value) {
|
|
527
|
+
return String(value).replace(
|
|
528
|
+
/[&<>"']/g,
|
|
529
|
+
(char) => ({
|
|
530
|
+
"&": "&",
|
|
531
|
+
"<": "<",
|
|
532
|
+
">": ">",
|
|
533
|
+
'"': """,
|
|
534
|
+
"'": "'"
|
|
535
|
+
})[char]
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// src/telemetry.js
|
|
540
|
+
var HARDCODED_FALLBACK_HOST = "https://a.kunchenguid.com";
|
|
541
|
+
var UMAMI_PATH = "/api/send";
|
|
542
|
+
var DEFAULT_HOSTNAME = "cli";
|
|
543
|
+
var DEFAULT_TITLE = "Lavish Editor CLI";
|
|
544
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 1e3;
|
|
545
|
+
function resolveTelemetryConfig(input) {
|
|
546
|
+
const optOut = String(input.env.LAVISH_AXI_TELEMETRY || "").trim().toLowerCase();
|
|
547
|
+
if (optOut === "0" || optOut === "false" || optOut === "off") {
|
|
548
|
+
return { enabled: false, host: "", websiteID: "" };
|
|
549
|
+
}
|
|
550
|
+
const websiteID = String(input.env.LAVISH_AXI_UMAMI_WEBSITE_ID || "").trim() || input.buildWebsiteID.trim();
|
|
551
|
+
if (!websiteID) {
|
|
552
|
+
return { enabled: false, host: "", websiteID: "" };
|
|
553
|
+
}
|
|
554
|
+
const host = String(input.env.LAVISH_AXI_UMAMI_HOST || "").trim() || input.buildHost.trim() || HARDCODED_FALLBACK_HOST;
|
|
555
|
+
return { enabled: true, host, websiteID };
|
|
556
|
+
}
|
|
557
|
+
function getBuildTimeUmamiHost() {
|
|
558
|
+
return "";
|
|
559
|
+
}
|
|
560
|
+
function getBuildTimeUmamiWebsiteID() {
|
|
561
|
+
return "";
|
|
562
|
+
}
|
|
563
|
+
function createTelemetryClient(config) {
|
|
564
|
+
if (!config.enabled || !config.websiteID) {
|
|
565
|
+
return new NoopTelemetryClient();
|
|
566
|
+
}
|
|
567
|
+
const endpoint = normalizeEndpoint(config.host);
|
|
568
|
+
if (!endpoint) {
|
|
569
|
+
return new NoopTelemetryClient();
|
|
570
|
+
}
|
|
571
|
+
return new HttpTelemetryClient(endpoint, config);
|
|
572
|
+
}
|
|
573
|
+
var defaultClient = null;
|
|
574
|
+
function initDefaultTelemetry(init) {
|
|
575
|
+
const resolved = resolveTelemetryConfig({
|
|
576
|
+
env: init.env || process.env,
|
|
577
|
+
buildHost: getBuildTimeUmamiHost(),
|
|
578
|
+
buildWebsiteID: getBuildTimeUmamiWebsiteID()
|
|
579
|
+
});
|
|
580
|
+
defaultClient = createTelemetryClient({
|
|
581
|
+
enabled: resolved.enabled,
|
|
582
|
+
host: resolved.host,
|
|
583
|
+
websiteID: resolved.websiteID,
|
|
584
|
+
app: init.app,
|
|
585
|
+
version: init.version,
|
|
586
|
+
platform: init.platform,
|
|
587
|
+
arch: init.arch
|
|
588
|
+
});
|
|
589
|
+
return defaultClient;
|
|
590
|
+
}
|
|
591
|
+
var NoopTelemetryClient = class {
|
|
592
|
+
track() {
|
|
593
|
+
}
|
|
594
|
+
pageview() {
|
|
595
|
+
}
|
|
596
|
+
async close() {
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
var HttpTelemetryClient = class {
|
|
600
|
+
constructor(endpoint, config) {
|
|
601
|
+
this.endpoint = endpoint;
|
|
602
|
+
this.websiteID = config.websiteID;
|
|
603
|
+
this.app = config.app;
|
|
604
|
+
this.version = config.version;
|
|
605
|
+
this.platform = config.platform || "";
|
|
606
|
+
this.arch = config.arch || "";
|
|
607
|
+
this.fetchImpl = config.fetch || fetch;
|
|
608
|
+
this.timeoutMs = config.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS;
|
|
609
|
+
this.userAgent = `${config.app}/${config.version} telemetry`;
|
|
610
|
+
this.inFlight = /* @__PURE__ */ new Set();
|
|
611
|
+
this.closed = false;
|
|
612
|
+
}
|
|
613
|
+
track(name, fields = {}) {
|
|
614
|
+
if (this.closed) return;
|
|
615
|
+
const trimmed = String(name || "").trim();
|
|
616
|
+
if (!trimmed) return;
|
|
617
|
+
this.send(trimmed, eventURL(this.app, trimmed), fields);
|
|
618
|
+
}
|
|
619
|
+
pageview(path5, fields = {}) {
|
|
620
|
+
if (this.closed) return;
|
|
621
|
+
this.send("", normalizePagePath(path5), fields);
|
|
622
|
+
}
|
|
623
|
+
async close(timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
624
|
+
this.closed = true;
|
|
625
|
+
if (this.inFlight.size === 0 || timeoutMs <= 0) return;
|
|
626
|
+
const drained = Promise.allSettled(Array.from(this.inFlight)).then(() => void 0);
|
|
627
|
+
await Promise.race([
|
|
628
|
+
drained,
|
|
629
|
+
new Promise((resolve) => {
|
|
630
|
+
setTimeout(resolve, timeoutMs).unref?.();
|
|
631
|
+
})
|
|
632
|
+
]);
|
|
633
|
+
}
|
|
634
|
+
send(name, url, fields) {
|
|
635
|
+
const data = { ...fields };
|
|
636
|
+
if (this.platform && data.platform === void 0) data.platform = this.platform;
|
|
637
|
+
if (this.arch && data.arch === void 0) data.arch = this.arch;
|
|
638
|
+
if (data.version === void 0) data.version = this.version;
|
|
639
|
+
const payload = {
|
|
640
|
+
type: "event",
|
|
641
|
+
payload: {
|
|
642
|
+
website: this.websiteID,
|
|
643
|
+
hostname: DEFAULT_HOSTNAME,
|
|
644
|
+
title: DEFAULT_TITLE,
|
|
645
|
+
url,
|
|
646
|
+
name,
|
|
647
|
+
data,
|
|
648
|
+
timestamp: Math.floor(Date.now() / 1e3)
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
let body;
|
|
652
|
+
try {
|
|
653
|
+
body = JSON.stringify(payload);
|
|
654
|
+
} catch {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const request = this.fire(body);
|
|
658
|
+
this.inFlight.add(request);
|
|
659
|
+
request.finally(() => this.inFlight.delete(request));
|
|
660
|
+
}
|
|
661
|
+
async fire(body) {
|
|
662
|
+
try {
|
|
663
|
+
const response = await this.fetchImpl(this.endpoint, {
|
|
664
|
+
method: "POST",
|
|
665
|
+
headers: {
|
|
666
|
+
"Content-Type": "application/json",
|
|
667
|
+
"User-Agent": this.userAgent
|
|
668
|
+
},
|
|
669
|
+
body,
|
|
670
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
671
|
+
});
|
|
672
|
+
try {
|
|
673
|
+
await response.body?.cancel?.();
|
|
674
|
+
} catch {
|
|
675
|
+
}
|
|
676
|
+
} catch {
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
function normalizeEndpoint(host) {
|
|
681
|
+
let url;
|
|
682
|
+
try {
|
|
683
|
+
url = new URL(String(host || "").trim());
|
|
684
|
+
} catch {
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
if (!url.protocol || !url.host) return null;
|
|
688
|
+
const pathname = url.pathname.replace(/\/+$/, "");
|
|
689
|
+
url.pathname = pathname.endsWith(UMAMI_PATH) ? pathname : pathname + UMAMI_PATH;
|
|
690
|
+
return url.toString();
|
|
691
|
+
}
|
|
692
|
+
function eventURL(app, name) {
|
|
693
|
+
if (!name) return `app://${app}`;
|
|
694
|
+
return `app://${app}/${name.replace(/\./g, "/")}`;
|
|
695
|
+
}
|
|
696
|
+
function normalizePagePath(path5) {
|
|
697
|
+
const trimmed = String(path5 || "").trim();
|
|
698
|
+
if (!trimmed) return "/";
|
|
699
|
+
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// src/cli.js
|
|
703
|
+
var COMMANDS = /* @__PURE__ */ new Set(["open", "poll", "end", "server"]);
|
|
704
|
+
var DESCRIPTION = "Lavish Editor helps agents turn rich HTML artifacts into collaborative human review surfaces. First generate an interactive HTML artifact for the user to inspect, then run `lavish-axi <html-file>` so the user can visually review it, annotate elements, queue prompts, and send feedback back through `lavish-axi poll`.";
|
|
705
|
+
var VERSION = "0.1.0";
|
|
706
|
+
async function run(argv) {
|
|
707
|
+
await ensureStateDir();
|
|
708
|
+
const normalizedArgv = normalizeArgv(argv);
|
|
709
|
+
const command = telemetryCommandName(argv);
|
|
710
|
+
const telemetry = initDefaultTelemetry({
|
|
711
|
+
app: "lavish-axi",
|
|
712
|
+
version: VERSION,
|
|
713
|
+
platform: process.platform,
|
|
714
|
+
arch: process.arch
|
|
715
|
+
});
|
|
716
|
+
telemetry.pageview(`/${command}`, { command });
|
|
717
|
+
try {
|
|
718
|
+
await runAxiCli({
|
|
719
|
+
description: DESCRIPTION,
|
|
720
|
+
version: VERSION,
|
|
721
|
+
argv: normalizedArgv,
|
|
722
|
+
topLevelHelp: TOP_LEVEL_HELP,
|
|
723
|
+
hooks: { binaryNames: ["lavish-axi"] },
|
|
724
|
+
home: async () => createHomeOutput({
|
|
725
|
+
bin: process.argv[1] || "lavish-axi",
|
|
726
|
+
sessions: await visibleSessions()
|
|
727
|
+
}),
|
|
728
|
+
commands: {
|
|
729
|
+
open: openCommand,
|
|
730
|
+
poll: pollCommand,
|
|
731
|
+
end: endCommand,
|
|
732
|
+
server: serverCommand
|
|
733
|
+
},
|
|
734
|
+
getCommandHelp
|
|
735
|
+
});
|
|
736
|
+
telemetry.track("command", { command, status: "success" });
|
|
737
|
+
} catch (error) {
|
|
738
|
+
telemetry.track("command", { command, status: "error" });
|
|
739
|
+
throw error;
|
|
740
|
+
} finally {
|
|
741
|
+
await telemetry.close(1e3);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
function collapseHomeDirectory(file, home) {
|
|
745
|
+
if (file === home) {
|
|
746
|
+
return "~";
|
|
747
|
+
}
|
|
748
|
+
if (file.startsWith(`${home}${path4.sep}`)) {
|
|
749
|
+
return `~${file.slice(home.length)}`;
|
|
750
|
+
}
|
|
751
|
+
return file;
|
|
752
|
+
}
|
|
753
|
+
function normalizeArgv(argv) {
|
|
754
|
+
const first = argv[0];
|
|
755
|
+
if (!first || COMMANDS.has(first)) {
|
|
756
|
+
return argv;
|
|
757
|
+
}
|
|
758
|
+
if (first.startsWith("-")) {
|
|
759
|
+
return argv.some((arg) => isHtmlPath(arg)) ? ["open", ...argv] : argv;
|
|
760
|
+
}
|
|
761
|
+
return ["open", ...argv];
|
|
762
|
+
}
|
|
763
|
+
function telemetryCommandName(argv) {
|
|
764
|
+
const normalized = normalizeArgv(argv);
|
|
765
|
+
return normalized[0] && !normalized[0].startsWith("-") ? normalized[0] : "home";
|
|
766
|
+
}
|
|
767
|
+
function createHomeOutput({ bin, sessions }) {
|
|
768
|
+
return {
|
|
769
|
+
bin: collapseHomeDirectory(bin, os2.homedir()),
|
|
770
|
+
description: DESCRIPTION,
|
|
771
|
+
sessions: sessions.map((session) => ({
|
|
772
|
+
file: session.file,
|
|
773
|
+
status: session.status,
|
|
774
|
+
url: session.url,
|
|
775
|
+
pending_prompts: session.pending_prompts || 0
|
|
776
|
+
})),
|
|
777
|
+
use_cases: [
|
|
778
|
+
"Implementation plans with diagrams, mockups, data flow, and code snippets",
|
|
779
|
+
"Design explorations with multiple visual options side by side",
|
|
780
|
+
"PR and code review explainers with annotated diffs and findings",
|
|
781
|
+
"Interactive prototypes with sliders, knobs, forms, and animation tuning",
|
|
782
|
+
"Reports, research summaries, incident writeups, and learning guides",
|
|
783
|
+
"Custom editing interfaces for triage, config editing, prompt tuning, dataset curation, and structured config editing"
|
|
784
|
+
],
|
|
785
|
+
artifact_guidance: [
|
|
786
|
+
"Use clear sections, headings, tabs, cards, tables, diagrams, and spatial layout instead of long prose",
|
|
787
|
+
"Include concrete artifacts such as mockups, SVG diagrams, annotated code snippets, diffs, data-flow charts, and examples",
|
|
788
|
+
"For exploration, show multiple options side by side and label the tradeoff each option makes",
|
|
789
|
+
"For code review, render the relevant diff or snippets with inline annotations and severity-coded findings",
|
|
790
|
+
"For custom editors, include controls that let the user adjust values, then call `window.lavish.queuePrompt(...)` with the requested change",
|
|
791
|
+
"End interactive workflows with an obvious way for the user to queue prompts and send them back"
|
|
792
|
+
],
|
|
793
|
+
visual_guidance: [
|
|
794
|
+
"Choose a clear visual point of view that matches the artifact: editorial, technical dashboard, dense control room, refined memo, playful prototype, brutalist review board, etc.",
|
|
795
|
+
"Use typography, spacing, color, and layout deliberately; avoid generic system-font cards on white unless that restraint is the actual design choice",
|
|
796
|
+
"Use visual hierarchy to make the most important decisions, risks, tradeoffs, and next actions obvious at a glance",
|
|
797
|
+
"Prefer diagrams, tables, annotated snippets, side-by-side comparisons, and spatial layouts over long prose",
|
|
798
|
+
"For explorations, make options visually distinct and label the tradeoff each option makes",
|
|
799
|
+
"For interactive artifacts, provide obvious controls and queue the user's chosen values with `window.lavish.queuePrompt(...)`",
|
|
800
|
+
"Make the artifact responsive and readable; visual polish should improve comprehension, not distract from review"
|
|
801
|
+
],
|
|
802
|
+
help: [
|
|
803
|
+
"Run `lavish-axi <html-file>` to open or resume a Lavish Editor session",
|
|
804
|
+
"Run `lavish-axi poll <html-file>` to wait for user feedback",
|
|
805
|
+
"Run `lavish-axi end <html-file>` to end a session"
|
|
806
|
+
]
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
function createOpenOutput({ file, url, status }) {
|
|
810
|
+
return {
|
|
811
|
+
session: { file, url, status },
|
|
812
|
+
next_step: `Tell the user to open ${url} to review the artifact in Lavish Editor, then run \`lavish-axi poll ${file}\`. This command long-polls until the user sends feedback or ends the session. Do not pass --timeout-ms during normal agent use. Do not set a short shell timeout; either run it without a timeout or set the shell timeout above 10 minutes. After applying feedback, run \`lavish-axi poll ${file} --agent-reply "<message for the user>"\` without --timeout-ms to show your response in Lavish Editor and wait for more feedback.`
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
async function openCommand(args) {
|
|
816
|
+
const file = args.find((arg) => !arg.startsWith("-"));
|
|
817
|
+
if (!file) {
|
|
818
|
+
throw new AxiError("HTML file path is required", "VALIDATION_ERROR", ["Run `lavish-axi <html-file>`"]);
|
|
819
|
+
}
|
|
820
|
+
await assertHtmlFile(file);
|
|
821
|
+
const absolute = await canonicalFile(file);
|
|
822
|
+
const baseUrl = await ensureServer();
|
|
823
|
+
const response = await postJson(`${baseUrl}/api/sessions`, { file: absolute });
|
|
824
|
+
if (shouldOpenBrowser(args, process.env)) {
|
|
825
|
+
try {
|
|
826
|
+
const open = (await import("open")).default;
|
|
827
|
+
await open(response.url);
|
|
828
|
+
} catch {
|
|
829
|
+
response.status = "ready";
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return createOpenOutput({ file: absolute, url: response.url, status: response.status || "opened" });
|
|
833
|
+
}
|
|
834
|
+
function shouldOpenBrowser(args, env) {
|
|
835
|
+
return !args.includes("--no-open") && env.LAVISH_AXI_NO_OPEN !== "1";
|
|
836
|
+
}
|
|
837
|
+
async function pollCommand(args) {
|
|
838
|
+
const file = args[0];
|
|
839
|
+
if (!file) {
|
|
840
|
+
throw new AxiError("HTML file path is required", "VALIDATION_ERROR", ["Run `lavish-axi poll <html-file>`"]);
|
|
841
|
+
}
|
|
842
|
+
const absolute = await canonicalFile(file);
|
|
843
|
+
const baseUrl = await ensureServer();
|
|
844
|
+
const agentReply = flagValue(args, "--agent-reply");
|
|
845
|
+
if (agentReply) {
|
|
846
|
+
await postJson(`${baseUrl}/api/${sessionKey(absolute)}/agent-reply`, { text: agentReply });
|
|
847
|
+
}
|
|
848
|
+
const timeoutMs = flagValue(args, "--timeout-ms");
|
|
849
|
+
const timeoutQuery = timeoutMs ? `&timeoutMs=${encodeURIComponent(timeoutMs)}` : "";
|
|
850
|
+
const response = await fetchJson(`${baseUrl}/api/poll?file=${encodeURIComponent(absolute)}${timeoutQuery}`);
|
|
851
|
+
return createPollOutput({ file: absolute, response });
|
|
852
|
+
}
|
|
853
|
+
function createPollOutput({ file, response }) {
|
|
854
|
+
if (response.status === "missing") {
|
|
855
|
+
throw new AxiError("No active Lavish Editor session for this file", "NOT_FOUND", [
|
|
856
|
+
`Run \`lavish-axi ${file}\` first`
|
|
857
|
+
]);
|
|
858
|
+
}
|
|
859
|
+
if (response.status === "feedback") {
|
|
860
|
+
return {
|
|
861
|
+
session: { file, status: "feedback" },
|
|
862
|
+
dom_snapshot: response.dom_snapshot || "",
|
|
863
|
+
prompts: response.prompts || [],
|
|
864
|
+
next_step: `Apply the requested changes to ${file}, then run \`lavish-axi poll ${file} --agent-reply "<message for the user>"\` without --timeout-ms unless the user ended the session. The poll command waits until the user sends more feedback or ends the session; do not set a short shell timeout, or set the shell timeout above 10 minutes.`
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
if (response.status === "ended") {
|
|
868
|
+
return { session: { file, status: "ended" } };
|
|
869
|
+
}
|
|
870
|
+
return {
|
|
871
|
+
session: { file, status: response.status || "waiting" },
|
|
872
|
+
next_step: `No user feedback arrived before the optional timeout. Run \`lavish-axi poll ${file}\` without --timeout-ms to wait indefinitely.`
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
async function endCommand(args) {
|
|
876
|
+
const file = args[0];
|
|
877
|
+
if (!file) {
|
|
878
|
+
throw new AxiError("HTML file path is required", "VALIDATION_ERROR", ["Run `lavish-axi end <html-file>`"]);
|
|
879
|
+
}
|
|
880
|
+
const absolute = await canonicalFile(file);
|
|
881
|
+
const baseUrl = await ensureServer();
|
|
882
|
+
const response = await postJson(`${baseUrl}/api/end`, { file: absolute });
|
|
883
|
+
return { session: { file: absolute, status: response.status || "ended" } };
|
|
884
|
+
}
|
|
885
|
+
async function serverCommand(args) {
|
|
886
|
+
const port = Number(flagValue(args, "--port") || defaultPort());
|
|
887
|
+
await serve({ port, stateFile: stateFile() });
|
|
888
|
+
return "";
|
|
889
|
+
}
|
|
890
|
+
async function visibleSessions() {
|
|
891
|
+
const store = new SessionStore(stateFile());
|
|
892
|
+
return (await store.listSessions()).filter((session) => session.status !== "ended");
|
|
893
|
+
}
|
|
894
|
+
async function assertHtmlFile(file) {
|
|
895
|
+
if (!isHtmlPath(file)) {
|
|
896
|
+
throw new AxiError("Lavish Editor expects an HTML file", "VALIDATION_ERROR", ["Run `lavish-axi <html-file>`"]);
|
|
897
|
+
}
|
|
898
|
+
try {
|
|
899
|
+
await access(file);
|
|
900
|
+
} catch {
|
|
901
|
+
throw new AxiError(`File not found: ${file}`, "NOT_FOUND", [
|
|
902
|
+
"Create the HTML artifact first, then run `lavish-axi <html-file>`"
|
|
903
|
+
]);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
function isHtmlPath(file) {
|
|
907
|
+
return file.toLowerCase().endsWith(".html") || file.toLowerCase().endsWith(".htm");
|
|
908
|
+
}
|
|
909
|
+
async function ensureServer() {
|
|
910
|
+
const port = defaultPort();
|
|
911
|
+
const baseUrl = `http://localhost:${port}`;
|
|
912
|
+
if (await isHealthy(baseUrl)) {
|
|
913
|
+
return baseUrl;
|
|
914
|
+
}
|
|
915
|
+
await startServer(port);
|
|
916
|
+
const deadline = Date.now() + 5e3;
|
|
917
|
+
while (Date.now() < deadline) {
|
|
918
|
+
if (await isHealthy(baseUrl)) {
|
|
919
|
+
return baseUrl;
|
|
920
|
+
}
|
|
921
|
+
await delay(100);
|
|
922
|
+
}
|
|
923
|
+
throw new AxiError("Lavish Editor server did not start", "SERVER_ERROR", [
|
|
924
|
+
`Run \`lavish-axi server --port ${port}\` to inspect server startup`
|
|
925
|
+
]);
|
|
926
|
+
}
|
|
927
|
+
async function startServer(port) {
|
|
928
|
+
await ensureStateDir();
|
|
929
|
+
const bin = fileURLToPath(new URL("../bin/lavish-axi.js", import.meta.url));
|
|
930
|
+
const child = spawn(process.execPath, [bin, "server", "--port", String(port)], {
|
|
931
|
+
detached: true,
|
|
932
|
+
stdio: "ignore",
|
|
933
|
+
env: { ...process.env, LAVISH_AXI_NO_OPEN: "1" }
|
|
934
|
+
});
|
|
935
|
+
child.unref();
|
|
936
|
+
}
|
|
937
|
+
async function isHealthy(baseUrl) {
|
|
938
|
+
try {
|
|
939
|
+
const response = await fetch(`${baseUrl}/health`);
|
|
940
|
+
return response.ok;
|
|
941
|
+
} catch {
|
|
942
|
+
return false;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
async function fetchJson(url) {
|
|
946
|
+
const response = await fetch(url);
|
|
947
|
+
if (!response.ok) {
|
|
948
|
+
throw new AxiError(`Lavish Editor request failed: ${response.status}`, "SERVER_ERROR");
|
|
949
|
+
}
|
|
950
|
+
return response.json();
|
|
951
|
+
}
|
|
952
|
+
async function postJson(url, body) {
|
|
953
|
+
const response = await fetch(url, {
|
|
954
|
+
method: "POST",
|
|
955
|
+
headers: { "content-type": "application/json" },
|
|
956
|
+
body: JSON.stringify(body)
|
|
957
|
+
});
|
|
958
|
+
if (!response.ok) {
|
|
959
|
+
throw new AxiError(`Lavish Editor request failed: ${response.status}`, "SERVER_ERROR");
|
|
960
|
+
}
|
|
961
|
+
return response.json();
|
|
962
|
+
}
|
|
963
|
+
function flagValue(args, flag) {
|
|
964
|
+
const index = args.indexOf(flag);
|
|
965
|
+
if (index === -1) {
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
return args[index + 1] || null;
|
|
969
|
+
}
|
|
970
|
+
function delay(ms) {
|
|
971
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
972
|
+
}
|
|
973
|
+
function getCommandHelp(command) {
|
|
974
|
+
return COMMAND_HELP[command] || null;
|
|
975
|
+
}
|
|
976
|
+
var TOP_LEVEL_HELP = `lavish-axi - Lavish Editor AXI
|
|
977
|
+
|
|
978
|
+
Usage:
|
|
979
|
+
lavish-axi
|
|
980
|
+
lavish-axi <html-file>
|
|
981
|
+
lavish-axi poll <html-file> [--agent-reply "..."]
|
|
982
|
+
lavish-axi end <html-file>
|
|
983
|
+
|
|
984
|
+
Note: poll long-polls indefinitely by default until the user sends feedback or ends the session. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. do not set a short shell timeout; either run it without a timeout or use a very high threshold above 10 minutes.
|
|
985
|
+
|
|
986
|
+
`;
|
|
987
|
+
var COMMAND_HELP = {
|
|
988
|
+
open: `Usage: lavish-axi <html-file> [--no-open]
|
|
989
|
+
|
|
990
|
+
Open or resume a Lavish Editor review session for an HTML artifact. Use --no-open when you need to ensure the server/session exists without opening another browser window.
|
|
991
|
+
`,
|
|
992
|
+
poll: `Usage: lavish-axi poll <html-file> [--agent-reply "..."]
|
|
993
|
+
|
|
994
|
+
This command long-polls indefinitely for queued user prompts, then returns them to the agent. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. do not set a short shell timeout; either run it without a timeout or use a very high threshold above 10 minutes so the user has time to review and send feedback. Use --agent-reply after applying prior feedback to display your response in Lavish Editor before waiting again.
|
|
995
|
+
`,
|
|
996
|
+
end: `Usage: lavish-axi end <html-file>
|
|
997
|
+
|
|
998
|
+
End a Lavish Editor session.
|
|
999
|
+
`,
|
|
1000
|
+
server: `Usage: lavish-axi server [--port 4387]
|
|
1001
|
+
|
|
1002
|
+
Run the local Lavish Editor server.
|
|
1003
|
+
`
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
// bin/lavish-axi.js
|
|
1007
|
+
await run(process.argv.slice(2));
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lavish-axi",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "HTML is the new markdown. Lavish is the new editor for your HTML artifacts.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"lavish-axi": "./dist/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"LICENSE",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "node scripts/build.js",
|
|
16
|
+
"start": "node dist/cli.mjs",
|
|
17
|
+
"lint": "eslint bin src test scripts",
|
|
18
|
+
"format": "prettier --write .",
|
|
19
|
+
"format:check": "prettier --check .",
|
|
20
|
+
"prepack": "npm run build",
|
|
21
|
+
"test": "node --test",
|
|
22
|
+
"typecheck": "tsc --noEmit"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"axi-sdk-js": "^0.1.5",
|
|
26
|
+
"chokidar": "^4.0.3",
|
|
27
|
+
"express": "^5.2.1",
|
|
28
|
+
"open": "^10.2.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@eslint/js": "^10.0.1",
|
|
32
|
+
"@types/node": "^25.6.2",
|
|
33
|
+
"esbuild": "^0.28.0",
|
|
34
|
+
"eslint": "^10.3.0",
|
|
35
|
+
"eslint-config-prettier": "^10.1.8",
|
|
36
|
+
"globals": "^17.6.0",
|
|
37
|
+
"prettier": "^3.8.3",
|
|
38
|
+
"typescript": "^5.9.3"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"axi",
|
|
45
|
+
"agent",
|
|
46
|
+
"cli",
|
|
47
|
+
"html",
|
|
48
|
+
"review",
|
|
49
|
+
"collaboration"
|
|
50
|
+
],
|
|
51
|
+
"license": "MIT",
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "https://github.com/kunchenguid/lavish.git"
|
|
55
|
+
},
|
|
56
|
+
"bugs": {
|
|
57
|
+
"url": "https://github.com/kunchenguid/lavish/issues"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://github.com/kunchenguid/lavish#readme",
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public"
|
|
62
|
+
}
|
|
63
|
+
}
|