@khanglvm/relay 0.13.5 → 0.13.7
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 +3 -1
- package/docs/AGENT.md +25 -0
- package/package.json +1 -1
- package/skills/relay/SKILL.md +19 -0
- package/src/cli.js +25 -10
- package/src/mcp.js +10 -8
- package/src/server.js +18 -1
- package/src/spec.js +133 -1
- package/src/ui/blocks.css +8 -0
- package/src/ui/blocks.js +25 -0
package/README.md
CHANGED
|
@@ -88,7 +88,7 @@ Node ≥ 18; Chart.js / Mermaid / Graphviz are vendored and lazy-loaded offline.
|
|
|
88
88
|
| | |
|
|
89
89
|
|---|---|
|
|
90
90
|
| `rly help` | every command at a glance |
|
|
91
|
-
| `rly view <file.md> …` | open a quick read-only board that renders local markdown file(s) — library-free; great for plans, READMEs, reports |
|
|
91
|
+
| `rly view <file.md> …` | open a quick read-only board that renders local markdown file(s), data files, or PDFs — library-free; great for plans, READMEs, reports, quotes |
|
|
92
92
|
| `rly install --target <agent>` | write relay's rules into an agent's instruction file — `claude` `codex` `cursor` `copilot` `kiro` `windsurf` `cline` `gemini` `opencode` `droid` `agents`; `--all`, `--scope`, `--print`, `--list` |
|
|
93
93
|
| `rly upgrade` | update the CLI **and** refresh the skill in one step (safe around open boards; `--dry-run`, `--cli-only`, `--skill-only`) |
|
|
94
94
|
| `rly mcp` | run relay as an MCP App server so boards render **inline** in the chat — **stdio** for local desktop hosts (Claude Desktop, Codex), or `rly mcp --http` (Streamable HTTP) for web/mobile/remote; `rly mcp config` / `rly mcp install --target claude\|codex` to register it |
|
|
@@ -164,6 +164,8 @@ npm test # zero-dep smoke tests (spawns real servers, fake-submits)
|
|
|
164
164
|
- **`video` block** — YouTube/Vimeo embeds, a direct media URL, or a local
|
|
165
165
|
video file streamed from the server with HTTP Range (seekable), never
|
|
166
166
|
embedded in the payload.
|
|
167
|
+
- **`pdf` block** — render local `.pdf` files or PDF URLs inline. Local PDFs
|
|
168
|
+
stream from the board server and are never embedded in the page payload.
|
|
167
169
|
- **Durable drafts / rescue** — every autosave mirrors to `localStorage`; a
|
|
168
170
|
board whose connection drops blocks further input instead of losing it, and
|
|
169
171
|
`rly rescue <id>` re-serves on the same port so an open tab reconnects.
|
package/docs/AGENT.md
CHANGED
|
@@ -151,6 +151,7 @@ Read a markdown file (no questions; library-free renderer; submit reads "Done"):
|
|
|
151
151
|
rly view PLAN.md # one file
|
|
152
152
|
rly view README.md CHANGELOG.md # several, each under a filename heading
|
|
153
153
|
rly view data.csv # .csv/.tsv/.json → a sortable, filterable table
|
|
154
|
+
rly view report.pdf # .pdf → inline PDF viewer
|
|
154
155
|
```
|
|
155
156
|
|
|
156
157
|
Show a git diff in one step (sugar — runs git diff, opens a diff board):
|
|
@@ -405,6 +406,10 @@ Rules of thumb:
|
|
|
405
406
|
{ "type": "video", "src": "https://youtu.be/dQw4w9WgXcQ", "title": "Demo walkthrough" }
|
|
406
407
|
{ "type": "video", "src": "recordings/demo.mp4", "title": "Local capture", "height": 360 }
|
|
407
408
|
|
|
409
|
+
// PDF — an http(s) PDF URL or a local .pdf file rendered inline. Local PDFs
|
|
410
|
+
// stream from the server and are never embedded in the page payload.
|
|
411
|
+
{ "type": "pdf", "src": "reports/quote.pdf", "title": "Quote", "height": 900 }
|
|
412
|
+
|
|
408
413
|
// HTML — sandboxed iframe; default height 360
|
|
409
414
|
{ "type": "html", "html": "<h1>Hello</h1>", "height": 360 }
|
|
410
415
|
{ "type": "html", "htmlFile": "viz.html", "height": 400 }
|
|
@@ -469,6 +474,7 @@ the question-types section above.
|
|
|
469
474
|
| `code` | code snippets, config examples, command output — syntax-highlighted + line-numbered; load from a file with `codeFile` |
|
|
470
475
|
| `diff` | proposed code changes / before-after — a unified diff rendered as a colored git-style comparison (no git needed) |
|
|
471
476
|
| `video` | demos, screen recordings, walkthroughs — YouTube/Vimeo embeds, a media URL, or a local video file (streamed) |
|
|
477
|
+
| `pdf` | quotes, reports, exports, forms — local `.pdf` files or PDF URLs rendered inline; local files stream from the board server |
|
|
472
478
|
| `image` | screenshots, mockup exports, photos — local files embed and work offline |
|
|
473
479
|
| `palette` | color palettes / themes — swatch cards with hover-hex + click-to-copy; pair with a `color` question to let the user pick |
|
|
474
480
|
| `kpi` | big-number metric cards (`items:[{label,value,delta?,dir?,sub?}]`) with up/down/flat-tinted deltas — at-a-glance numbers without a chart |
|
|
@@ -646,6 +652,25 @@ cancelled — with the full result JSON piped to stdin and `RLY_BOARD_ID`,
|
|
|
646
652
|
a `wait` that obtains a terminal result. Write a file your harness watches,
|
|
647
653
|
hit a webhook — whatever wakes you.
|
|
648
654
|
|
|
655
|
+
### Codex browser-board pattern
|
|
656
|
+
|
|
657
|
+
Codex does not have a normal, user-facing "wake this agent turn from a browser
|
|
658
|
+
submit" command. If the Codex turn stops waiting, a later board submit can leave
|
|
659
|
+
the user needing to prompt manually. So when an agent is running in Codex and
|
|
660
|
+
uses the browser board path, keep the waiter in the foreground until the user
|
|
661
|
+
submits:
|
|
662
|
+
|
|
663
|
+
```sh
|
|
664
|
+
rly ask --file spec.json --detach
|
|
665
|
+
rly wait b-xxxxx --timeout 1800 --while-active --idle-grace 300
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
If `rly wait` exits with `wait-timeout`, immediately run `rly result <boardId>`.
|
|
669
|
+
If it is still open and the user may continue, run `rly wait` again. Do not use
|
|
670
|
+
`--on-result` as the primary Codex return path; it can write files or hit
|
|
671
|
+
webhooks, but normal Codex CLI sessions do not expose a portable inbound API
|
|
672
|
+
that wakes the current agent turn.
|
|
673
|
+
|
|
649
674
|
## Editable diagrams — let the user redraw your mermaid
|
|
650
675
|
|
|
651
676
|
Add `"editable": true` to any mermaid block. The user gets an "Edit diagram"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanglvm/relay",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.7",
|
|
4
4
|
"description": "Question boards with rich blocks (markdown, charts, mermaid, tables, code, diffs, video, sandboxed HTML), clickable local file-links, and element-level annotations for AI coding agents (Claude Code, Codex, …): ask users structured questions, present interactive visuals, collect inline comments, read answers as JSON — in a local browser board OR rendered INLINE inside the Claude & Codex apps as an MCP App (SEP-1865).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
package/skills/relay/SKILL.md
CHANGED
|
@@ -86,6 +86,7 @@ priority call in a `rank` question — not in paragraphs. Unsure which exists? R
|
|
|
86
86
|
| `image` | screenshots / mockups / renders — zoom+pan; `pins:true` → click-to-drop point comments |
|
|
87
87
|
| `compare` | a before/after pair — draggable divider |
|
|
88
88
|
| `video` | a demo / screen recording / walkthrough |
|
|
89
|
+
| `pdf` | a quote / report / exported document that should render inline |
|
|
89
90
|
| `palette` | color schemes — swatch cards, hover-hex, click-to-copy |
|
|
90
91
|
| `typography` | type choices — specimens at given size/weight/font |
|
|
91
92
|
| `html` | anything bespoke — custom widgets, pixel-perfect mockups |
|
|
@@ -145,6 +146,22 @@ Push-wake instead of polling: add --on-result '<shell cmd>' to ask/show/reopen
|
|
|
145
146
|
(or --notify-cmd on wait) - the command runs the moment the board finishes,
|
|
146
147
|
with the full result JSON on stdin and RLY_BOARD_ID/RLY_STATUS/RLY_URL in env.
|
|
147
148
|
|
|
149
|
+
**Codex browser-board pattern.** Codex does not have a normal, user-facing
|
|
150
|
+
"wake this agent turn from a browser submit" command. If the Codex turn stops
|
|
151
|
+
waiting, a later board submit can leave the user needing to prompt manually.
|
|
152
|
+
So in Codex, keep the waiter in the foreground until the user submits:
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
rly ask --file spec.json --detach
|
|
156
|
+
rly wait b-xxxxx --timeout 1800 --while-active --idle-grace 300
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
If `rly wait` exits with `wait-timeout`, immediately run `rly result <boardId>`.
|
|
160
|
+
If it is still open and the user may continue, run `rly wait` again. Do not use
|
|
161
|
+
`--on-result` as the primary Codex return path; it can write files or hit
|
|
162
|
+
webhooks, but normal Codex CLI sessions do not expose a portable inbound API
|
|
163
|
+
that wakes the current agent turn.
|
|
164
|
+
|
|
148
165
|
Blocking mode (`rly ask --file spec.json --timeout 1800`, no --detach) is fine
|
|
149
166
|
ONLY when your shell tool has no execution time limit.
|
|
150
167
|
|
|
@@ -270,6 +287,8 @@ single/multi question.
|
|
|
270
287
|
{ "type": "video", "src": "recordings/demo.mp4", "title": "Local capture", "height": 360 }
|
|
271
288
|
// ^ YouTube/Vimeo URL embeds a player; an http(s) media URL or a local video
|
|
272
289
|
// file (mp4/webm/ogv/mov/mkv/m4v) plays inline (local files stream, not embedded).
|
|
290
|
+
{ "type": "pdf", "src": "reports/quote.pdf", "title": "Quote", "height": 900 }
|
|
291
|
+
// ^ local .pdf files and http(s) PDF URLs render inline; local files stream, not embedded.
|
|
273
292
|
{ "type": "html", "html": "<p>hi</p>", "height": 360 }
|
|
274
293
|
{ "type": "html", "htmlFile": "viz.html", "height": 400 }
|
|
275
294
|
{ "type": "image", "src": "screenshot.png" } // local file, URL, or data URI
|
package/src/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ import os from 'node:os';
|
|
|
4
4
|
import { spawn, spawnSync } from 'node:child_process';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { CliError, sleep, pollFor } from './util.js';
|
|
7
|
-
import { normalizeSpec, questionFromInline, SPEC_SCHEMA } from './spec.js';
|
|
7
|
+
import { assertSpecReady, normalizeSpec, questionFromInline, SPEC_SCHEMA } from './spec.js';
|
|
8
8
|
import {
|
|
9
9
|
createBoard,
|
|
10
10
|
loadBoard,
|
|
@@ -197,6 +197,7 @@ function readFileOrThrow(p) {
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
async function runOrDetach(record, args) {
|
|
200
|
+
await assertSpecReady(record.spec);
|
|
200
201
|
const timeoutSec = args.timeout !== undefined ? Math.max(0, Number.parseInt(args.timeout, 10) || 0) : 1800;
|
|
201
202
|
const port = args.port !== undefined ? Number.parseInt(args.port, 10) || 0 : 0;
|
|
202
203
|
const open = args.open !== false;
|
|
@@ -244,6 +245,7 @@ async function runOrDetach(record, args) {
|
|
|
244
245
|
async function cmdAsk(args, mode) {
|
|
245
246
|
const raw = await resolveSpecInput(args, mode);
|
|
246
247
|
const spec = normalizeSpec(raw);
|
|
248
|
+
await assertSpecReady(spec);
|
|
247
249
|
const record = createBoard(spec);
|
|
248
250
|
return runOrDetach(record, args);
|
|
249
251
|
}
|
|
@@ -275,17 +277,17 @@ async function cmdDiff(rest) {
|
|
|
275
277
|
}
|
|
276
278
|
const title = args.title || ('git diff' + (gitArgs.length ? ' ' + gitArgs.join(' ') : ''));
|
|
277
279
|
const spec = normalizeSpec({ title, blocks: [{ type: 'diff', diff, view: args.split ? 'split' : 'unified' }] });
|
|
280
|
+
await assertSpecReady(spec);
|
|
278
281
|
const record = createBoard(spec);
|
|
279
282
|
return runOrDetach(record, args);
|
|
280
283
|
}
|
|
281
284
|
|
|
282
|
-
// `rly view <file.md> [more.md …]` — quick read-only board that renders
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
// heading separates them. Sugar over `rly show` with markdown mdFile blocks.
|
|
285
|
+
// `rly view <file.md> [more.md …]` — quick read-only board that renders files
|
|
286
|
+
// with purpose-built blocks (markdown, data tables, PDF). With 2+ files a small
|
|
287
|
+
// filename heading separates them. Sugar over `rly show`.
|
|
286
288
|
async function cmdView(args) {
|
|
287
289
|
const files = args._;
|
|
288
|
-
if (!files.length) throw new CliError('usage: rly view <file.md|.csv|.json> [more …] [--title T] [--detach]');
|
|
290
|
+
if (!files.length) throw new CliError('usage: rly view <file.md|.csv|.json|.pdf> [more …] [--title T] [--detach]');
|
|
289
291
|
const multi = files.length > 1;
|
|
290
292
|
const DATA_EXT = new Set(['csv', 'tsv', 'json']);
|
|
291
293
|
const blocks = [];
|
|
@@ -293,12 +295,14 @@ async function cmdView(args) {
|
|
|
293
295
|
if (multi) blocks.push({ type: 'markdown', md: `## ${path.basename(f)}` });
|
|
294
296
|
const ext = path.extname(f).slice(1).toLowerCase();
|
|
295
297
|
if (DATA_EXT.has(ext)) blocks.push({ type: 'table', rowsFile: f, sortable: true, filterable: true, exportable: true });
|
|
298
|
+
else if (ext === 'pdf') blocks.push({ type: 'pdf', src: f, title: path.basename(f) });
|
|
296
299
|
else blocks.push({ type: 'markdown', mdFile: f });
|
|
297
300
|
}
|
|
298
301
|
const raw = { blocks };
|
|
299
302
|
raw.title = args.title || (multi ? `${files.length} files` : path.basename(files[0]));
|
|
300
303
|
raw.submitLabel = args.submitLabel || 'Done';
|
|
301
304
|
const spec = normalizeSpec(raw); // reads + validates each file, clear error if unreadable
|
|
305
|
+
await assertSpecReady(spec);
|
|
302
306
|
const record = createBoard(spec);
|
|
303
307
|
return runOrDetach(record, args);
|
|
304
308
|
}
|
|
@@ -445,6 +449,7 @@ async function cmdUpdate(args) {
|
|
|
445
449
|
} else {
|
|
446
450
|
throw new CliError('update needs --file <spec.json>, --title, --intro, or -q "...".', 4);
|
|
447
451
|
}
|
|
452
|
+
await assertSpecReady(spec);
|
|
448
453
|
|
|
449
454
|
let res;
|
|
450
455
|
try {
|
|
@@ -879,8 +884,8 @@ what you're about to output would look better than monospace text — or is a fi
|
|
|
879
884
|
the user should view — put it in relay instead of printing it.**
|
|
880
885
|
|
|
881
886
|
- **Markdown file / doc / README / plan / report** → \`rly view file.md\` (renders it
|
|
882
|
-
in the browser, no library; \`.csv\`/\`.json\` → a sortable table
|
|
883
|
-
doc into the terminal for the user to read.
|
|
887
|
+
in the browser, no library; \`.csv\`/\`.json\` → a sortable table; \`.pdf\` → an
|
|
888
|
+
inline PDF viewer). Never \`cat\` a doc into the terminal for the user to read.
|
|
884
889
|
- **Image / screenshot / mockup / diagram / chart / table** → a relay board with an
|
|
885
890
|
\`image\` block (or \`mermaid\`/\`graphviz\`/\`chart\`/\`table\`/\`kpi\`/\`compare\`). The
|
|
886
891
|
terminal can't show pixels; relay can — with click-to-zoom and comment-in-place.
|
|
@@ -889,6 +894,15 @@ the user should view — put it in relay instead of printing it.**
|
|
|
889
894
|
\`AskUserQuestion\` / ask-user tool. Build a spec and run
|
|
890
895
|
\`rly ask --file spec.json --detach\`, then \`rly wait <boardId>\`. **Holds in plan
|
|
891
896
|
mode too**; reserve \`ExitPlanMode\` for the final plan-approval gate only.
|
|
897
|
+
- **Codex-specific browser-board pattern:** Codex does not have a normal,
|
|
898
|
+
user-facing "wake this agent turn from a browser submit" command. In Codex,
|
|
899
|
+
keep the waiter in the foreground until the user submits:
|
|
900
|
+
\`rly ask --file spec.json --detach\` then
|
|
901
|
+
\`rly wait <boardId> --timeout 1800 --while-active --idle-grace 300\`.
|
|
902
|
+
If wait exits with \`wait-timeout\`, immediately run \`rly result <boardId>\`;
|
|
903
|
+
if it is still open and the user may continue, run \`rly wait\` again. Do not
|
|
904
|
+
use \`--on-result\` as the primary Codex return path; normal Codex CLI sessions
|
|
905
|
+
do not expose a portable inbound API that wakes the current agent turn.
|
|
892
906
|
- **A plan, structure, architecture, data, or prototype** → a relay board with
|
|
893
907
|
diagram/chart/table/code/image/html blocks — never ASCII diagrams or walls of prose.
|
|
894
908
|
- **"Show me the diff / git diff / these changes"** → \`rly diff\` (runs git diff →
|
|
@@ -899,7 +913,7 @@ the user should view — put it in relay instead of printing it.**
|
|
|
899
913
|
- **There's a purpose-built component for most content — use the MOST SPECIFIC one,
|
|
900
914
|
never plain prose when a block fits.** Blocks: \`table\` (sortable/filterable/CSV,
|
|
901
915
|
load from .csv/.json), \`chart\`, \`kpi\` (stat cards), \`mermaid\`/\`graphviz\`/\`plantuml\`,
|
|
902
|
-
\`code\`, \`diff\`, \`image\` (+\`pins\`), \`compare\` (before/after), \`video\`, \`palette\`,
|
|
916
|
+
\`code\`, \`diff\`, \`image\` (+\`pins\`), \`compare\` (before/after), \`video\`, \`pdf\`, \`palette\`,
|
|
903
917
|
\`typography\`, \`html\`. Question types: \`single\`/\`multi\`/\`yesno\`/\`scale\`/\`color\`/
|
|
904
918
|
\`text\`/\`textarea\` plus \`rank\` (prioritize), \`allocate\` (split a budget), \`checklist\`
|
|
905
919
|
(per-item sign-off). For a business user, reach for \`kpi\`+\`chart\`+\`table\` and
|
|
@@ -1385,7 +1399,8 @@ USAGE
|
|
|
1385
1399
|
rly ask ... --on-result "<cmd>" push-wake: run <cmd> when the board finishes (result JSON on stdin)
|
|
1386
1400
|
rly show --html-file viz.html visualization-only board (submit button = acknowledge)
|
|
1387
1401
|
rly view <file.md> [more.md …] quick read-only board rendering markdown file(s) (no lib)
|
|
1388
|
-
(.csv/.tsv/.json render as a filterable, sortable table
|
|
1402
|
+
(.csv/.tsv/.json render as a filterable, sortable table;
|
|
1403
|
+
.pdf streams in an inline PDF viewer)
|
|
1389
1404
|
rly diff [git args…] run git diff and show it in a diff board (--split, --detach,
|
|
1390
1405
|
--title; other args pass to git: rly diff --staged | HEAD~1 | -- path)
|
|
1391
1406
|
rly wait <id> [--timeout 3600] block until board finishes, print result JSON
|
package/src/mcp.js
CHANGED
|
@@ -19,7 +19,7 @@ import path from 'node:path';
|
|
|
19
19
|
import http from 'node:http';
|
|
20
20
|
import crypto from 'node:crypto';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
|
-
import { normalizeSpec, SPEC_SCHEMA } from './spec.js';
|
|
22
|
+
import { assertSpecReady, normalizeSpec, SPEC_SCHEMA } from './spec.js';
|
|
23
23
|
import { CliError } from './util.js';
|
|
24
24
|
|
|
25
25
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -143,7 +143,7 @@ function resourceMeta() {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
// ---------- request routing ----------
|
|
146
|
-
function buildResult(method, params, clientProtocol) {
|
|
146
|
+
async function buildResult(method, params, clientProtocol) {
|
|
147
147
|
switch (method) {
|
|
148
148
|
case 'initialize':
|
|
149
149
|
return {
|
|
@@ -202,7 +202,7 @@ function readResource(params) {
|
|
|
202
202
|
// ui/update-model-context as a best-effort context sync). Spec errors come back
|
|
203
203
|
// as an isError tool result (not a protocol error) so the model can see and fix
|
|
204
204
|
// them.
|
|
205
|
-
function callTool(params) {
|
|
205
|
+
async function callTool(params) {
|
|
206
206
|
const name = params && params.name;
|
|
207
207
|
if (name !== 'relay_ask' && name !== 'relay_show') {
|
|
208
208
|
return { content: [{ type: 'text', text: 'unknown tool: ' + name }], isError: true };
|
|
@@ -211,6 +211,7 @@ function callTool(params) {
|
|
|
211
211
|
let spec;
|
|
212
212
|
try {
|
|
213
213
|
spec = normalizeSpec(args);
|
|
214
|
+
await assertSpecReady(spec);
|
|
214
215
|
} catch (err) {
|
|
215
216
|
const msg = err instanceof CliError ? err.message : String((err && err.message) || err);
|
|
216
217
|
return { content: [{ type: 'text', text: 'relay: invalid board spec — ' + msg }], isError: true };
|
|
@@ -265,7 +266,7 @@ export function runMcp() {
|
|
|
265
266
|
catch { stdoutOpen = false; }
|
|
266
267
|
};
|
|
267
268
|
|
|
268
|
-
function handleLine(line) {
|
|
269
|
+
async function handleLine(line) {
|
|
269
270
|
let msg;
|
|
270
271
|
try { msg = JSON.parse(line); } catch {
|
|
271
272
|
send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
|
|
@@ -278,7 +279,7 @@ export function runMcp() {
|
|
|
278
279
|
if (typeof m.method !== 'string') continue; // a response to us — we issue none
|
|
279
280
|
let result;
|
|
280
281
|
try {
|
|
281
|
-
result = buildResult(m.method, m.params || {});
|
|
282
|
+
result = await buildResult(m.method, m.params || {});
|
|
282
283
|
} catch (err) {
|
|
283
284
|
if (isRequest) {
|
|
284
285
|
send({ jsonrpc: '2.0', id: m.id, error: { code: err.code || -32603, message: err.message || String(err) } });
|
|
@@ -290,6 +291,7 @@ export function runMcp() {
|
|
|
290
291
|
}
|
|
291
292
|
|
|
292
293
|
let buf = '';
|
|
294
|
+
let queue = Promise.resolve();
|
|
293
295
|
process.stdin.setEncoding('utf8');
|
|
294
296
|
process.stdin.on('data', (chunk) => {
|
|
295
297
|
buf += chunk;
|
|
@@ -298,7 +300,7 @@ export function runMcp() {
|
|
|
298
300
|
const line = buf.slice(0, idx);
|
|
299
301
|
buf = buf.slice(idx + 1);
|
|
300
302
|
const trimmed = line.trim();
|
|
301
|
-
if (trimmed) handleLine(trimmed);
|
|
303
|
+
if (trimmed) queue = queue.then(() => handleLine(trimmed)).catch(() => {});
|
|
302
304
|
}
|
|
303
305
|
});
|
|
304
306
|
|
|
@@ -406,7 +408,7 @@ export function runMcpHttp({ port = DEFAULT_HTTP_PORT, host = '127.0.0.1', token
|
|
|
406
408
|
let body = '';
|
|
407
409
|
let aborted = false;
|
|
408
410
|
req.on('data', (c) => { body += c; if (body.length > MAX_BODY) { aborted = true; req.destroy(); } });
|
|
409
|
-
req.on('end', () => {
|
|
411
|
+
req.on('end', async () => {
|
|
410
412
|
if (aborted) { res.writeHead(413, base); return res.end(); }
|
|
411
413
|
let msg;
|
|
412
414
|
try { msg = JSON.parse(body); } catch {
|
|
@@ -421,7 +423,7 @@ export function runMcpHttp({ port = DEFAULT_HTTP_PORT, host = '127.0.0.1', token
|
|
|
421
423
|
if (m.method === 'initialize') isInit = true;
|
|
422
424
|
const isRequest = m.id !== undefined && m.id !== null;
|
|
423
425
|
try {
|
|
424
|
-
const result = buildResult(m.method, m.params || {});
|
|
426
|
+
const result = await buildResult(m.method, m.params || {});
|
|
425
427
|
if (isRequest) responses.push({ jsonrpc: '2.0', id: m.id, result });
|
|
426
428
|
} catch (err) {
|
|
427
429
|
if (isRequest) responses.push({ jsonrpc: '2.0', id: m.id, error: { code: err.code || -32603, message: err.message || String(err) } });
|
package/src/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import { spawn } from 'node:child_process';
|
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { loadBoard, saveBoard, saveRunning, removeRunning, loadPref, savePref } from './store.js';
|
|
9
9
|
import { openUrl } from './open.js';
|
|
10
|
+
import { assertSpecReady } from './spec.js';
|
|
10
11
|
|
|
11
12
|
const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ui');
|
|
12
13
|
const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -40,7 +41,9 @@ function readUi(name) {
|
|
|
40
41
|
|
|
41
42
|
// Strips block bodies for the client payload: html blocks ship only metadata
|
|
42
43
|
// (their bodies are served via /html/b/<id>), embedded images ship only
|
|
43
|
-
// metadata (bytes served via /img/b/<id>),
|
|
44
|
+
// metadata (bytes served via /img/b/<id>), streamed local media ships only
|
|
45
|
+
// metadata (bytes served via /video/b/<id> or /pdf/b/<id>), everything else
|
|
46
|
+
// ships as-is.
|
|
44
47
|
function clientBlock(b) {
|
|
45
48
|
// Cross-block fields preserved when we ship metadata-only (ref = reference-link
|
|
46
49
|
// target name; pins = image coordinate comments). The default `return b` path
|
|
@@ -58,6 +61,9 @@ function clientBlock(b) {
|
|
|
58
61
|
if (b && b.type === 'video' && typeof b.file === 'string') {
|
|
59
62
|
return { id: b.id, type: 'video', title: b.title, height: b.height, mime: b.mime, hasFile: true, ...extra };
|
|
60
63
|
}
|
|
64
|
+
if (b && b.type === 'pdf' && typeof b.file === 'string') {
|
|
65
|
+
return { id: b.id, type: 'pdf', title: b.title, height: b.height, mime: b.mime, hasFile: true, ...extra };
|
|
66
|
+
}
|
|
61
67
|
return b;
|
|
62
68
|
}
|
|
63
69
|
|
|
@@ -587,6 +593,11 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
587
593
|
!Array.isArray(next.questions) || !Array.isArray(next.blocks)) {
|
|
588
594
|
return sendJson(res, 400, { error: 'spec must be an object with questions[] and blocks[] arrays' });
|
|
589
595
|
}
|
|
596
|
+
try {
|
|
597
|
+
await assertSpecReady(next);
|
|
598
|
+
} catch (err) {
|
|
599
|
+
return sendJson(res, 400, { error: err && err.message ? err.message : String(err) });
|
|
600
|
+
}
|
|
590
601
|
record.spec = next;
|
|
591
602
|
rev++;
|
|
592
603
|
saveBoard(record);
|
|
@@ -619,6 +630,12 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
619
630
|
const block = findBlock(record.spec, blockId, 'video');
|
|
620
631
|
if (!block || typeof block.file !== 'string') return sendJson(res, 404, { error: `no local video block "${blockId}"` });
|
|
621
632
|
streamFile(req, res, block.file, block.mime || 'application/octet-stream');
|
|
633
|
+
} else if ((req.method === 'GET' || req.method === 'HEAD') && pathname.startsWith('/pdf/b/')) {
|
|
634
|
+
// Local PDF bytes, Range-streamed for the browser's built-in PDF viewer.
|
|
635
|
+
const blockId = decodeURIComponent(pathname.slice('/pdf/b/'.length));
|
|
636
|
+
const block = findBlock(record.spec, blockId, 'pdf');
|
|
637
|
+
if (!block || typeof block.file !== 'string') return sendJson(res, 404, { error: `no local pdf block "${blockId}"` });
|
|
638
|
+
streamFile(req, res, block.file, block.mime || 'application/pdf');
|
|
622
639
|
} else if (req.method === 'GET' && pathname === '/html/board') {
|
|
623
640
|
// Legacy alias → the board's first html block.
|
|
624
641
|
const block = firstBoardHtml(record.spec);
|
package/src/spec.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import vm from 'node:vm';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
3
5
|
import { CliError } from './util.js';
|
|
4
6
|
|
|
5
7
|
export const TYPES = ['single', 'multi', 'yesno', 'text', 'textarea', 'scale', 'color', 'rank', 'checklist', 'allocate'];
|
|
@@ -39,7 +41,7 @@ const HTML_HEIGHT = { min: 100, max: 2400, boardDefault: 400, questionDefault: 3
|
|
|
39
41
|
|
|
40
42
|
// Block heights clamp to the same window; defaults vary per block type.
|
|
41
43
|
const BLOCK_HEIGHT = { min: 100, max: 2400 };
|
|
42
|
-
export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'diff', 'video', 'html', 'image', 'palette', 'kpi', 'typography', 'compare'];
|
|
44
|
+
export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'diff', 'video', 'pdf', 'html', 'image', 'palette', 'kpi', 'typography', 'compare'];
|
|
43
45
|
const CHART_KINDS = ['bar', 'line', 'pie', 'doughnut', 'radar', 'scatter'];
|
|
44
46
|
|
|
45
47
|
// code/diff blocks may load their text from a local file (like htmlFile). Caps
|
|
@@ -55,6 +57,9 @@ const VIDEO_MIMES = {
|
|
|
55
57
|
};
|
|
56
58
|
const VIDEO_MAX_BYTES = 512 * 1024 * 1024;
|
|
57
59
|
|
|
60
|
+
const PDF_MIME = 'application/pdf';
|
|
61
|
+
const PDF_MAX_BYTES = 512 * 1024 * 1024;
|
|
62
|
+
|
|
58
63
|
// image blocks: local files are embedded as data URIs at spec time (the page
|
|
59
64
|
// then loads them via /img/b/<id>), so boards stay self-contained offline.
|
|
60
65
|
const IMAGE_MIMES = {
|
|
@@ -62,6 +67,9 @@ const IMAGE_MIMES = {
|
|
|
62
67
|
webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif', bmp: 'image/bmp',
|
|
63
68
|
};
|
|
64
69
|
const IMAGE_MAX_BYTES = 8 * 1024 * 1024;
|
|
70
|
+
const VENDOR_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'vendor');
|
|
71
|
+
|
|
72
|
+
let mermaidValidatorPromise = null;
|
|
65
73
|
|
|
66
74
|
const asStr = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
|
|
67
75
|
|
|
@@ -219,6 +227,99 @@ function parseVideoEmbed(src) {
|
|
|
219
227
|
return null;
|
|
220
228
|
}
|
|
221
229
|
|
|
230
|
+
function compactError(err) {
|
|
231
|
+
const raw = err && (err.str || err.message) ? (err.str || err.message) : String(err || 'unknown error');
|
|
232
|
+
const msg = String(raw).replace(/\s+/g, ' ').trim();
|
|
233
|
+
return msg.length > 500 ? msg.slice(0, 497) + '...' : msg;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function mermaidValidator() {
|
|
237
|
+
if (mermaidValidatorPromise) return mermaidValidatorPromise;
|
|
238
|
+
mermaidValidatorPromise = (async () => {
|
|
239
|
+
const sourcePath = path.join(VENDOR_DIR, 'mermaid.min.js');
|
|
240
|
+
let source;
|
|
241
|
+
try {
|
|
242
|
+
source = fs.readFileSync(sourcePath, 'utf8');
|
|
243
|
+
} catch {
|
|
244
|
+
throw new CliError('mermaid blocks need the vendored parser (vendor/mermaid.min.js is missing).');
|
|
245
|
+
}
|
|
246
|
+
const quietConsole = { log() {}, info() {}, warn() {}, error() {}, debug() {} };
|
|
247
|
+
const context = {
|
|
248
|
+
console: quietConsole,
|
|
249
|
+
setTimeout,
|
|
250
|
+
clearTimeout,
|
|
251
|
+
};
|
|
252
|
+
context.globalThis = context;
|
|
253
|
+
context.window = { addEventListener() {} };
|
|
254
|
+
vm.createContext(context);
|
|
255
|
+
try {
|
|
256
|
+
vm.runInContext(source, context, { filename: sourcePath });
|
|
257
|
+
} catch (err) {
|
|
258
|
+
throw new CliError(`could not load mermaid parser: ${compactError(err)}`);
|
|
259
|
+
}
|
|
260
|
+
if (!context.mermaid || typeof context.mermaid.parse !== 'function') {
|
|
261
|
+
throw new CliError('could not load mermaid parser: vendor/mermaid.min.js did not expose mermaid.parse.');
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
context.mermaid.initialize({
|
|
265
|
+
startOnLoad: false,
|
|
266
|
+
securityLevel: 'strict',
|
|
267
|
+
suppressErrorRendering: true,
|
|
268
|
+
});
|
|
269
|
+
} catch {
|
|
270
|
+
// Some Mermaid versions dislike repeated initialize calls; parse below is
|
|
271
|
+
// the real readiness gate.
|
|
272
|
+
}
|
|
273
|
+
return context.mermaid;
|
|
274
|
+
})();
|
|
275
|
+
return mermaidValidatorPromise;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function assertMermaidSyntax(code, where) {
|
|
279
|
+
const mermaid = await mermaidValidator();
|
|
280
|
+
try {
|
|
281
|
+
const parsed = mermaid.parse(code);
|
|
282
|
+
if (parsed && typeof parsed.then === 'function') await parsed;
|
|
283
|
+
} catch (err) {
|
|
284
|
+
throw new CliError(`${where}: invalid mermaid syntax — ${compactError(err)}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function* allBlocks(spec) {
|
|
289
|
+
const boardBlocks = Array.isArray(spec.blocks) ? spec.blocks : [];
|
|
290
|
+
for (let i = 0; i < boardBlocks.length; i++) {
|
|
291
|
+
yield { block: boardBlocks[i], where: `board.blocks[${i}]${boardBlocks[i]?.id ? ` (${boardBlocks[i].id})` : ''}` };
|
|
292
|
+
}
|
|
293
|
+
const questions = Array.isArray(spec.questions) ? spec.questions : [];
|
|
294
|
+
for (let qi = 0; qi < questions.length; qi++) {
|
|
295
|
+
const q = questions[qi] || {};
|
|
296
|
+
const qBlocks = Array.isArray(q.blocks) ? q.blocks : [];
|
|
297
|
+
for (let bi = 0; bi < qBlocks.length; bi++) {
|
|
298
|
+
yield { block: qBlocks[bi], where: `questions[${qi}].blocks[${bi}]${qBlocks[bi]?.id ? ` (${qBlocks[bi].id})` : ''}` };
|
|
299
|
+
}
|
|
300
|
+
const opts = Array.isArray(q.options) ? q.options : [];
|
|
301
|
+
for (let oi = 0; oi < opts.length; oi++) {
|
|
302
|
+
const oBlocks = Array.isArray(opts[oi]?.blocks) ? opts[oi].blocks : [];
|
|
303
|
+
for (let bi = 0; bi < oBlocks.length; bi++) {
|
|
304
|
+
yield {
|
|
305
|
+
block: oBlocks[bi],
|
|
306
|
+
where: `questions[${qi}].options[${oi}].blocks[${bi}]${oBlocks[bi]?.id ? ` (${oBlocks[bi].id})` : ''}`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function assertSpecReady(spec) {
|
|
314
|
+
if (!spec || typeof spec !== 'object') return spec;
|
|
315
|
+
if (spec.__relayReady === true) return spec;
|
|
316
|
+
for (const { block, where } of allBlocks(spec)) {
|
|
317
|
+
if (block && block.type === 'mermaid') await assertMermaidSyntax(block.code || '', where);
|
|
318
|
+
}
|
|
319
|
+
Object.defineProperty(spec, '__relayReady', { value: true, enumerable: false, configurable: true });
|
|
320
|
+
return spec;
|
|
321
|
+
}
|
|
322
|
+
|
|
222
323
|
// Normalizes one block object. `id` is the already-assigned block id.
|
|
223
324
|
// Returns the normalized block (with a guaranteed string `type` + `id`).
|
|
224
325
|
function normalizeBlock(rawBlock, id, cwd, where) {
|
|
@@ -343,6 +444,37 @@ function normalizeBlock(rawBlock, id, cwd, where) {
|
|
|
343
444
|
return block;
|
|
344
445
|
}
|
|
345
446
|
|
|
447
|
+
if (type === 'pdf') {
|
|
448
|
+
const src = asStr(rawBlock.src ?? rawBlock.file ?? rawBlock.url).trim();
|
|
449
|
+
if (!src) throw new CliError(`${where}: pdf block needs a "src" (http(s) PDF URL or local .pdf file path).`);
|
|
450
|
+
const block = { id, type: 'pdf' };
|
|
451
|
+
if (rawBlock.title !== undefined) block.title = asStr(rawBlock.title);
|
|
452
|
+
if (rawBlock.alt !== undefined && block.title === undefined) block.title = asStr(rawBlock.alt);
|
|
453
|
+
if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
|
|
454
|
+
if (/^https?:/i.test(src)) {
|
|
455
|
+
block.src = src;
|
|
456
|
+
block.mime = PDF_MIME;
|
|
457
|
+
if (!block.title) block.title = path.basename(src.split(/[?#]/)[0]) || 'PDF';
|
|
458
|
+
return block;
|
|
459
|
+
}
|
|
460
|
+
const p = path.resolve(cwd, src);
|
|
461
|
+
const ext = path.extname(p).slice(1).toLowerCase();
|
|
462
|
+
if (ext !== 'pdf') throw new CliError(`${where}: unsupported pdf extension ".${ext}" — use a local .pdf file or an http(s) PDF URL.`);
|
|
463
|
+
let stat;
|
|
464
|
+
try {
|
|
465
|
+
stat = fs.statSync(p);
|
|
466
|
+
} catch {
|
|
467
|
+
throw new CliError(`${where}: cannot read pdf "${src}" (resolved: ${p})`);
|
|
468
|
+
}
|
|
469
|
+
if (stat.size > PDF_MAX_BYTES) {
|
|
470
|
+
throw new CliError(`${where}: pdf "${src}" is ${(stat.size / 1024 / 1024).toFixed(0)}MB — max ${PDF_MAX_BYTES / 1024 / 1024}MB.`);
|
|
471
|
+
}
|
|
472
|
+
block.file = p;
|
|
473
|
+
block.mime = PDF_MIME;
|
|
474
|
+
if (!block.title) block.title = path.basename(p);
|
|
475
|
+
return block;
|
|
476
|
+
}
|
|
477
|
+
|
|
346
478
|
if (type === 'chart') {
|
|
347
479
|
const hasConfig = rawBlock.config && typeof rawBlock.config === 'object' && !Array.isArray(rawBlock.config);
|
|
348
480
|
const hasShorthand =
|
package/src/ui/blocks.css
CHANGED
|
@@ -278,6 +278,14 @@
|
|
|
278
278
|
.blk-video-embed { aspect-ratio: 16 / 9; height: auto; }
|
|
279
279
|
.blk-videocap { margin-top: 8px; font-size: 0.84rem; color: var(--muted); text-align: center; }
|
|
280
280
|
|
|
281
|
+
/* ---------- PDF (local stream / direct URL) ---------- */
|
|
282
|
+
.blk-pdfwrap { margin: 0; }
|
|
283
|
+
.blk-pdf {
|
|
284
|
+
display: block; width: 100%; height: min(900px, 78vh); min-height: 420px;
|
|
285
|
+
border: 0; border-radius: 10px; background: var(--card);
|
|
286
|
+
}
|
|
287
|
+
.blk-pdfwrap.blk-full .blk-pdf { height: calc(100vh - 92px); min-height: 0; }
|
|
288
|
+
|
|
281
289
|
/* ---------- table ---------- */
|
|
282
290
|
.blk-table {
|
|
283
291
|
width: 100%;
|
package/src/ui/blocks.js
CHANGED
|
@@ -692,6 +692,27 @@
|
|
|
692
692
|
return wrap;
|
|
693
693
|
}
|
|
694
694
|
|
|
695
|
+
// ---------- PDF (local stream or direct URL) ----------
|
|
696
|
+
function renderPdf(block, ctx, blockId) {
|
|
697
|
+
const wrap = el('div', { class: 'blk-pdfwrap' });
|
|
698
|
+
const src = block.hasFile ? '/pdf/b/' + encodeURIComponent(blockId) : block.src;
|
|
699
|
+
const title = block.title || 'PDF';
|
|
700
|
+
const frame = el('iframe', {
|
|
701
|
+
class: 'blk-pdf',
|
|
702
|
+
src,
|
|
703
|
+
title,
|
|
704
|
+
loading: 'lazy',
|
|
705
|
+
});
|
|
706
|
+
if (block.height != null) frame.style.height = clampHeight(block.height, 900) + 'px';
|
|
707
|
+
frame.addEventListener('error', () => {
|
|
708
|
+
wrap.replaceChildren(el('div', { class: 'blk-error' }, 'PDF failed to load'));
|
|
709
|
+
});
|
|
710
|
+
wrap.append(frame);
|
|
711
|
+
if (block.title) wrap.append(el('div', { class: 'blk-videocap' }, block.title));
|
|
712
|
+
attachViewer(wrap, { zoomEl: null, label: 'pdf', comment: wholeBlockComment(ctx, blockId, 'pdf') });
|
|
713
|
+
return wrap;
|
|
714
|
+
}
|
|
715
|
+
|
|
695
716
|
// ---------- table ----------
|
|
696
717
|
function normalizeColumns(columns) {
|
|
697
718
|
return (columns || []).map((c, idx) => {
|
|
@@ -2199,6 +2220,10 @@
|
|
|
2199
2220
|
inner = renderVideo(block, ctx, blockId);
|
|
2200
2221
|
wrapper.append(inner);
|
|
2201
2222
|
break;
|
|
2223
|
+
case 'pdf':
|
|
2224
|
+
inner = renderPdf(block, ctx, blockId);
|
|
2225
|
+
wrapper.append(inner);
|
|
2226
|
+
break;
|
|
2202
2227
|
case 'chart':
|
|
2203
2228
|
inner = renderChart(block, ctx, blockId);
|
|
2204
2229
|
wrapper.append(inner);
|