@khanglvm/relay 0.13.4 → 0.13.6
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 +13 -9
- package/docs/AGENT.md +27 -1
- package/package.json +1 -1
- package/skills/relay/SKILL.md +19 -0
- package/src/cli.js +19 -9
- package/src/mcp-ui/board.js +23 -9
- package/src/mcp.js +7 -6
- package/src/server.js +12 -1
- package/src/spec.js +35 -1
- package/src/ui/blocks.css +8 -0
- package/src/ui/blocks.js +25 -0
package/README.md
CHANGED
|
@@ -61,10 +61,11 @@ rly mcp config
|
|
|
61
61
|
|
|
62
62
|
When the agent calls a relay tool, the host renders relay's `ui://relay/board`
|
|
63
63
|
resource in a sandboxed iframe, hands it the board spec, and the user's answers
|
|
64
|
-
flow back to the agent
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
flow back to the agent as a `ui/message` user turn, with
|
|
65
|
+
`ui/update-model-context` used as a best-effort structured context sync —
|
|
66
|
+
markdown, code, diffs, tables, charts, mermaid/graphviz diagrams, images, and
|
|
67
|
+
forms, all in-chat. The classic browser board (`rly ask` / `rly show`) is
|
|
68
|
+
unchanged; pick whichever surface fits.
|
|
68
69
|
|
|
69
70
|
## What it improves
|
|
70
71
|
|
|
@@ -87,7 +88,7 @@ Node ≥ 18; Chart.js / Mermaid / Graphviz are vendored and lazy-loaded offline.
|
|
|
87
88
|
| | |
|
|
88
89
|
|---|---|
|
|
89
90
|
| `rly help` | every command at a glance |
|
|
90
|
-
| `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 |
|
|
91
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` |
|
|
92
93
|
| `rly upgrade` | update the CLI **and** refresh the skill in one step (safe around open boards; `--dry-run`, `--cli-only`, `--skill-only`) |
|
|
93
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 |
|
|
@@ -114,10 +115,11 @@ npm test # zero-dep smoke tests (spawns real servers, fake-submits)
|
|
|
114
115
|
- **Same board, postMessage transport.** The inline board reuses relay's block
|
|
115
116
|
renderer (markdown, code, diff, table, chart, mermaid, graphviz, image, html)
|
|
116
117
|
over the MCP Apps JSON-RPC bridge: the spec arrives as the tool result, the
|
|
117
|
-
user's
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
user's submit goes back via `ui/message` so the agent resumes, the structured
|
|
119
|
+
payload is also offered through `ui/update-model-context`, the iframe
|
|
120
|
+
auto-sizes via `ui/notifications/size-changed`, and vendored Chart.js /
|
|
121
|
+
Mermaid / Viz.js load on demand through the host's `resources/read` (no
|
|
122
|
+
`/vendor` route, no server in the sandbox).
|
|
121
123
|
- **One-command setup** — `rly mcp install --target claude|codex` writes the
|
|
122
124
|
host config; `rly mcp config` prints the snippet for any MCP host. The classic
|
|
123
125
|
browser board is untouched.
|
|
@@ -162,6 +164,8 @@ npm test # zero-dep smoke tests (spawns real servers, fake-submits)
|
|
|
162
164
|
- **`video` block** — YouTube/Vimeo embeds, a direct media URL, or a local
|
|
163
165
|
video file streamed from the server with HTTP Range (seekable), never
|
|
164
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.
|
|
165
169
|
- **Durable drafts / rescue** — every autosave mirrors to `localStorage`; a
|
|
166
170
|
board whose connection drops blocks further input instead of losing it, and
|
|
167
171
|
`rly rescue <id>` re-serves on the same port so an open tab reconnects.
|
package/docs/AGENT.md
CHANGED
|
@@ -111,7 +111,8 @@ finalizes when your call completes — so the user sees it build, not a blank wa
|
|
|
111
111
|
Both take **the exact same board spec** documented below (the tool `inputSchema`
|
|
112
112
|
*is* this spec). Call the tool with your spec; the host shows the board, the user
|
|
113
113
|
fills it in, and their answers come back to you (answers, per-question notes,
|
|
114
|
-
comment)
|
|
114
|
+
comment) as a user-message turn, with structured context synced when the host
|
|
115
|
+
supports it — read them just as you would the CLI's result JSON. There is **no
|
|
115
116
|
`--detach`/`rly wait` dance, no stdout parsing, and no timeout** in this mode; the
|
|
116
117
|
board stays live until the user submits and the host delivers the result.
|
|
117
118
|
|
|
@@ -150,6 +151,7 @@ Read a markdown file (no questions; library-free renderer; submit reads "Done"):
|
|
|
150
151
|
rly view PLAN.md # one file
|
|
151
152
|
rly view README.md CHANGELOG.md # several, each under a filename heading
|
|
152
153
|
rly view data.csv # .csv/.tsv/.json → a sortable, filterable table
|
|
154
|
+
rly view report.pdf # .pdf → inline PDF viewer
|
|
153
155
|
```
|
|
154
156
|
|
|
155
157
|
Show a git diff in one step (sugar — runs git diff, opens a diff board):
|
|
@@ -404,6 +406,10 @@ Rules of thumb:
|
|
|
404
406
|
{ "type": "video", "src": "https://youtu.be/dQw4w9WgXcQ", "title": "Demo walkthrough" }
|
|
405
407
|
{ "type": "video", "src": "recordings/demo.mp4", "title": "Local capture", "height": 360 }
|
|
406
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
|
+
|
|
407
413
|
// HTML — sandboxed iframe; default height 360
|
|
408
414
|
{ "type": "html", "html": "<h1>Hello</h1>", "height": 360 }
|
|
409
415
|
{ "type": "html", "htmlFile": "viz.html", "height": 400 }
|
|
@@ -468,6 +474,7 @@ the question-types section above.
|
|
|
468
474
|
| `code` | code snippets, config examples, command output — syntax-highlighted + line-numbered; load from a file with `codeFile` |
|
|
469
475
|
| `diff` | proposed code changes / before-after — a unified diff rendered as a colored git-style comparison (no git needed) |
|
|
470
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 |
|
|
471
478
|
| `image` | screenshots, mockup exports, photos — local files embed and work offline |
|
|
472
479
|
| `palette` | color palettes / themes — swatch cards with hover-hex + click-to-copy; pair with a `color` question to let the user pick |
|
|
473
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 |
|
|
@@ -645,6 +652,25 @@ cancelled — with the full result JSON piped to stdin and `RLY_BOARD_ID`,
|
|
|
645
652
|
a `wait` that obtains a terminal result. Write a file your harness watches,
|
|
646
653
|
hit a webhook — whatever wakes you.
|
|
647
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
|
+
|
|
648
674
|
## Editable diagrams — let the user redraw your mermaid
|
|
649
675
|
|
|
650
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.6",
|
|
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
|
@@ -279,13 +279,12 @@ async function cmdDiff(rest) {
|
|
|
279
279
|
return runOrDetach(record, args);
|
|
280
280
|
}
|
|
281
281
|
|
|
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.
|
|
282
|
+
// `rly view <file.md> [more.md …]` — quick read-only board that renders files
|
|
283
|
+
// with purpose-built blocks (markdown, data tables, PDF). With 2+ files a small
|
|
284
|
+
// filename heading separates them. Sugar over `rly show`.
|
|
286
285
|
async function cmdView(args) {
|
|
287
286
|
const files = args._;
|
|
288
|
-
if (!files.length) throw new CliError('usage: rly view <file.md|.csv|.json> [more …] [--title T] [--detach]');
|
|
287
|
+
if (!files.length) throw new CliError('usage: rly view <file.md|.csv|.json|.pdf> [more …] [--title T] [--detach]');
|
|
289
288
|
const multi = files.length > 1;
|
|
290
289
|
const DATA_EXT = new Set(['csv', 'tsv', 'json']);
|
|
291
290
|
const blocks = [];
|
|
@@ -293,6 +292,7 @@ async function cmdView(args) {
|
|
|
293
292
|
if (multi) blocks.push({ type: 'markdown', md: `## ${path.basename(f)}` });
|
|
294
293
|
const ext = path.extname(f).slice(1).toLowerCase();
|
|
295
294
|
if (DATA_EXT.has(ext)) blocks.push({ type: 'table', rowsFile: f, sortable: true, filterable: true, exportable: true });
|
|
295
|
+
else if (ext === 'pdf') blocks.push({ type: 'pdf', src: f, title: path.basename(f) });
|
|
296
296
|
else blocks.push({ type: 'markdown', mdFile: f });
|
|
297
297
|
}
|
|
298
298
|
const raw = { blocks };
|
|
@@ -879,8 +879,8 @@ what you're about to output would look better than monospace text — or is a fi
|
|
|
879
879
|
the user should view — put it in relay instead of printing it.**
|
|
880
880
|
|
|
881
881
|
- **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.
|
|
882
|
+
in the browser, no library; \`.csv\`/\`.json\` → a sortable table; \`.pdf\` → an
|
|
883
|
+
inline PDF viewer). Never \`cat\` a doc into the terminal for the user to read.
|
|
884
884
|
- **Image / screenshot / mockup / diagram / chart / table** → a relay board with an
|
|
885
885
|
\`image\` block (or \`mermaid\`/\`graphviz\`/\`chart\`/\`table\`/\`kpi\`/\`compare\`). The
|
|
886
886
|
terminal can't show pixels; relay can — with click-to-zoom and comment-in-place.
|
|
@@ -889,6 +889,15 @@ the user should view — put it in relay instead of printing it.**
|
|
|
889
889
|
\`AskUserQuestion\` / ask-user tool. Build a spec and run
|
|
890
890
|
\`rly ask --file spec.json --detach\`, then \`rly wait <boardId>\`. **Holds in plan
|
|
891
891
|
mode too**; reserve \`ExitPlanMode\` for the final plan-approval gate only.
|
|
892
|
+
- **Codex-specific browser-board pattern:** Codex does not have a normal,
|
|
893
|
+
user-facing "wake this agent turn from a browser submit" command. In Codex,
|
|
894
|
+
keep the waiter in the foreground until the user submits:
|
|
895
|
+
\`rly ask --file spec.json --detach\` then
|
|
896
|
+
\`rly wait <boardId> --timeout 1800 --while-active --idle-grace 300\`.
|
|
897
|
+
If wait exits with \`wait-timeout\`, immediately run \`rly result <boardId>\`;
|
|
898
|
+
if it is still open and the user may continue, run \`rly wait\` again. Do not
|
|
899
|
+
use \`--on-result\` as the primary Codex return path; normal Codex CLI sessions
|
|
900
|
+
do not expose a portable inbound API that wakes the current agent turn.
|
|
892
901
|
- **A plan, structure, architecture, data, or prototype** → a relay board with
|
|
893
902
|
diagram/chart/table/code/image/html blocks — never ASCII diagrams or walls of prose.
|
|
894
903
|
- **"Show me the diff / git diff / these changes"** → \`rly diff\` (runs git diff →
|
|
@@ -899,7 +908,7 @@ the user should view — put it in relay instead of printing it.**
|
|
|
899
908
|
- **There's a purpose-built component for most content — use the MOST SPECIFIC one,
|
|
900
909
|
never plain prose when a block fits.** Blocks: \`table\` (sortable/filterable/CSV,
|
|
901
910
|
load from .csv/.json), \`chart\`, \`kpi\` (stat cards), \`mermaid\`/\`graphviz\`/\`plantuml\`,
|
|
902
|
-
\`code\`, \`diff\`, \`image\` (+\`pins\`), \`compare\` (before/after), \`video\`, \`palette\`,
|
|
911
|
+
\`code\`, \`diff\`, \`image\` (+\`pins\`), \`compare\` (before/after), \`video\`, \`pdf\`, \`palette\`,
|
|
903
912
|
\`typography\`, \`html\`. Question types: \`single\`/\`multi\`/\`yesno\`/\`scale\`/\`color\`/
|
|
904
913
|
\`text\`/\`textarea\` plus \`rank\` (prioritize), \`allocate\` (split a budget), \`checklist\`
|
|
905
914
|
(per-item sign-off). For a business user, reach for \`kpi\`+\`chart\`+\`table\` and
|
|
@@ -1385,7 +1394,8 @@ USAGE
|
|
|
1385
1394
|
rly ask ... --on-result "<cmd>" push-wake: run <cmd> when the board finishes (result JSON on stdin)
|
|
1386
1395
|
rly show --html-file viz.html visualization-only board (submit button = acknowledge)
|
|
1387
1396
|
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
|
|
1397
|
+
(.csv/.tsv/.json render as a filterable, sortable table;
|
|
1398
|
+
.pdf streams in an inline PDF viewer)
|
|
1389
1399
|
rly diff [git args…] run git diff and show it in a diff board (--split, --detach,
|
|
1390
1400
|
--title; other args pass to git: rly diff --staged | HEAD~1 | -- path)
|
|
1391
1401
|
rly wait <id> [--timeout 3600] block until board finishes, print result JSON
|
package/src/mcp-ui/board.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// host app (Claude desktop/mobile, Codex, …) as a sandboxed inline iframe.
|
|
5
5
|
// There is no local HTTP server here: every exchange with the host travels over
|
|
6
6
|
// JSON-RPC on window.postMessage — the spec arrives as the tool result, the
|
|
7
|
-
// user's
|
|
8
|
-
// (Chart.js / Mermaid / Viz.js) are pulled through the host's
|
|
7
|
+
// user's final submission goes back as a `ui/message` user turn, and vendored
|
|
8
|
+
// libraries (Chart.js / Mermaid / Viz.js) are pulled through the host's
|
|
9
|
+
// `resources/read`.
|
|
9
10
|
//
|
|
10
11
|
// Rich blocks are rendered by the SAME window.RelayBlocks as the browser board
|
|
11
12
|
// (markdown, code, diff, table, chart, mermaid, graphviz, image, html), so the
|
|
@@ -957,19 +958,30 @@
|
|
|
957
958
|
annotations: data.annotations,
|
|
958
959
|
};
|
|
959
960
|
const text = summarize(data);
|
|
960
|
-
let
|
|
961
|
+
let messageDelivered = false;
|
|
962
|
+
let contextDelivered = false;
|
|
961
963
|
try {
|
|
964
|
+
// A completed relay form is a user reply, not passive context. Some hosts
|
|
965
|
+
// ACK `ui/update-model-context` without starting a new model turn, so send
|
|
966
|
+
// the transcript as a user message first to wake the agent reliably.
|
|
967
|
+
await request('ui/message', { role: 'user', content: { type: 'text', text } });
|
|
968
|
+
messageDelivered = true;
|
|
969
|
+
} catch {
|
|
970
|
+
// Older/leaner hosts may not expose app-initiated messages.
|
|
971
|
+
}
|
|
972
|
+
try {
|
|
973
|
+
// Keep the structured payload available to hosts that attach app context.
|
|
974
|
+
// This is best-effort because context updates are intentionally silent.
|
|
962
975
|
await request('ui/update-model-context', { content: [{ type: 'text', text }], structuredContent: structured });
|
|
963
|
-
|
|
976
|
+
contextDelivered = true;
|
|
964
977
|
} catch {
|
|
965
|
-
//
|
|
966
|
-
try { await request('ui/message', { role: 'user', content: { type: 'text', text } }); delivered = true; } catch { /* give up gracefully */ }
|
|
978
|
+
// If ui/message worked, the agent still receives the submission transcript.
|
|
967
979
|
}
|
|
968
980
|
submitted = true;
|
|
969
|
-
showDone(
|
|
981
|
+
showDone(messageDelivered, contextDelivered);
|
|
970
982
|
}
|
|
971
983
|
|
|
972
|
-
function showDone(
|
|
984
|
+
function showDone(messageDelivered, contextDelivered) {
|
|
973
985
|
// Collapse: leave fullscreen, drop the whole form for a one-line confirmation
|
|
974
986
|
// so the host shrinks the iframe to a small footprint in the transcript.
|
|
975
987
|
if (Annotate) { try { Annotate.teardown(); } catch { /* nothing to tear down */ } }
|
|
@@ -977,8 +989,10 @@
|
|
|
977
989
|
app.replaceChildren(el('div', { class: 'mcp-done' },
|
|
978
990
|
el('span', { class: 'mark' }, '✓'),
|
|
979
991
|
el('span', { class: 'lead' }, QS.length ? 'Submitted' : 'Acknowledged'),
|
|
980
|
-
el('span', { class: 'sub' },
|
|
992
|
+
el('span', { class: 'sub' }, messageDelivered
|
|
981
993
|
? '· sent back to the agent'
|
|
994
|
+
: contextDelivered
|
|
995
|
+
? '· saved; send the agent a message to continue'
|
|
982
996
|
: '· tell the agent you’ve responded')));
|
|
983
997
|
// Report the small height immediately, then again next frame / after layout
|
|
984
998
|
// settles — beats hosts that only grow on debounced size events.
|
package/src/mcp.js
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
// • the tools `relay_ask` / `relay_show` link to it via _meta.ui.resourceUri
|
|
9
9
|
// • calling a tool returns the (normalized) board spec as structuredContent;
|
|
10
10
|
// the host renders the resource in a sandboxed iframe and forwards the spec
|
|
11
|
-
// • the iframe collects the user's answers and sends them back
|
|
12
|
-
//
|
|
11
|
+
// • the iframe collects the user's answers and sends them back as a user
|
|
12
|
+
// message; it also syncs model context when the host supports it
|
|
13
13
|
//
|
|
14
14
|
// Framing is the MCP stdio transport: newline-delimited JSON-RPC, one message
|
|
15
15
|
// per line, never embedded newlines. stdout carries ONLY protocol messages;
|
|
@@ -156,7 +156,7 @@ function buildResult(method, params, clientProtocol) {
|
|
|
156
156
|
},
|
|
157
157
|
serverInfo: { name: 'relay', version: PKG.version },
|
|
158
158
|
instructions:
|
|
159
|
-
'relay renders interactive boards inline. Call relay_ask to collect decisions/feedback with real form controls, or relay_show to present plans/diagrams/data — instead of asking in plain text.
|
|
159
|
+
'relay renders interactive boards inline. Call relay_ask to collect decisions/feedback with real form controls, or relay_show to present plans/diagrams/data — instead of asking in plain text. When the user submits, relay sends their answers back as a user message so the agent continues.',
|
|
160
160
|
};
|
|
161
161
|
case 'ping':
|
|
162
162
|
return {};
|
|
@@ -198,9 +198,10 @@ function readResource(params) {
|
|
|
198
198
|
|
|
199
199
|
// A tool call: normalize the spec and hand it to the host as structuredContent.
|
|
200
200
|
// The host renders ui://relay/board and forwards this spec to the iframe, which
|
|
201
|
-
// collects the answers and returns them via
|
|
202
|
-
//
|
|
203
|
-
// see and fix
|
|
201
|
+
// collects the answers and returns them via a UI-originated user message (with
|
|
202
|
+
// ui/update-model-context as a best-effort context sync). Spec errors come back
|
|
203
|
+
// as an isError tool result (not a protocol error) so the model can see and fix
|
|
204
|
+
// them.
|
|
204
205
|
function callTool(params) {
|
|
205
206
|
const name = params && params.name;
|
|
206
207
|
if (name !== 'relay_ask' && name !== 'relay_show') {
|
package/src/server.js
CHANGED
|
@@ -40,7 +40,9 @@ function readUi(name) {
|
|
|
40
40
|
|
|
41
41
|
// Strips block bodies for the client payload: html blocks ship only metadata
|
|
42
42
|
// (their bodies are served via /html/b/<id>), embedded images ship only
|
|
43
|
-
// metadata (bytes served via /img/b/<id>),
|
|
43
|
+
// metadata (bytes served via /img/b/<id>), streamed local media ships only
|
|
44
|
+
// metadata (bytes served via /video/b/<id> or /pdf/b/<id>), everything else
|
|
45
|
+
// ships as-is.
|
|
44
46
|
function clientBlock(b) {
|
|
45
47
|
// Cross-block fields preserved when we ship metadata-only (ref = reference-link
|
|
46
48
|
// target name; pins = image coordinate comments). The default `return b` path
|
|
@@ -58,6 +60,9 @@ function clientBlock(b) {
|
|
|
58
60
|
if (b && b.type === 'video' && typeof b.file === 'string') {
|
|
59
61
|
return { id: b.id, type: 'video', title: b.title, height: b.height, mime: b.mime, hasFile: true, ...extra };
|
|
60
62
|
}
|
|
63
|
+
if (b && b.type === 'pdf' && typeof b.file === 'string') {
|
|
64
|
+
return { id: b.id, type: 'pdf', title: b.title, height: b.height, mime: b.mime, hasFile: true, ...extra };
|
|
65
|
+
}
|
|
61
66
|
return b;
|
|
62
67
|
}
|
|
63
68
|
|
|
@@ -619,6 +624,12 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
619
624
|
const block = findBlock(record.spec, blockId, 'video');
|
|
620
625
|
if (!block || typeof block.file !== 'string') return sendJson(res, 404, { error: `no local video block "${blockId}"` });
|
|
621
626
|
streamFile(req, res, block.file, block.mime || 'application/octet-stream');
|
|
627
|
+
} else if ((req.method === 'GET' || req.method === 'HEAD') && pathname.startsWith('/pdf/b/')) {
|
|
628
|
+
// Local PDF bytes, Range-streamed for the browser's built-in PDF viewer.
|
|
629
|
+
const blockId = decodeURIComponent(pathname.slice('/pdf/b/'.length));
|
|
630
|
+
const block = findBlock(record.spec, blockId, 'pdf');
|
|
631
|
+
if (!block || typeof block.file !== 'string') return sendJson(res, 404, { error: `no local pdf block "${blockId}"` });
|
|
632
|
+
streamFile(req, res, block.file, block.mime || 'application/pdf');
|
|
622
633
|
} else if (req.method === 'GET' && pathname === '/html/board') {
|
|
623
634
|
// Legacy alias → the board's first html block.
|
|
624
635
|
const block = firstBoardHtml(record.spec);
|
package/src/spec.js
CHANGED
|
@@ -39,7 +39,7 @@ const HTML_HEIGHT = { min: 100, max: 2400, boardDefault: 400, questionDefault: 3
|
|
|
39
39
|
|
|
40
40
|
// Block heights clamp to the same window; defaults vary per block type.
|
|
41
41
|
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'];
|
|
42
|
+
export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'diff', 'video', 'pdf', 'html', 'image', 'palette', 'kpi', 'typography', 'compare'];
|
|
43
43
|
const CHART_KINDS = ['bar', 'line', 'pie', 'doughnut', 'radar', 'scatter'];
|
|
44
44
|
|
|
45
45
|
// code/diff blocks may load their text from a local file (like htmlFile). Caps
|
|
@@ -55,6 +55,9 @@ const VIDEO_MIMES = {
|
|
|
55
55
|
};
|
|
56
56
|
const VIDEO_MAX_BYTES = 512 * 1024 * 1024;
|
|
57
57
|
|
|
58
|
+
const PDF_MIME = 'application/pdf';
|
|
59
|
+
const PDF_MAX_BYTES = 512 * 1024 * 1024;
|
|
60
|
+
|
|
58
61
|
// image blocks: local files are embedded as data URIs at spec time (the page
|
|
59
62
|
// then loads them via /img/b/<id>), so boards stay self-contained offline.
|
|
60
63
|
const IMAGE_MIMES = {
|
|
@@ -343,6 +346,37 @@ function normalizeBlock(rawBlock, id, cwd, where) {
|
|
|
343
346
|
return block;
|
|
344
347
|
}
|
|
345
348
|
|
|
349
|
+
if (type === 'pdf') {
|
|
350
|
+
const src = asStr(rawBlock.src ?? rawBlock.file ?? rawBlock.url).trim();
|
|
351
|
+
if (!src) throw new CliError(`${where}: pdf block needs a "src" (http(s) PDF URL or local .pdf file path).`);
|
|
352
|
+
const block = { id, type: 'pdf' };
|
|
353
|
+
if (rawBlock.title !== undefined) block.title = asStr(rawBlock.title);
|
|
354
|
+
if (rawBlock.alt !== undefined && block.title === undefined) block.title = asStr(rawBlock.alt);
|
|
355
|
+
if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
|
|
356
|
+
if (/^https?:/i.test(src)) {
|
|
357
|
+
block.src = src;
|
|
358
|
+
block.mime = PDF_MIME;
|
|
359
|
+
if (!block.title) block.title = path.basename(src.split(/[?#]/)[0]) || 'PDF';
|
|
360
|
+
return block;
|
|
361
|
+
}
|
|
362
|
+
const p = path.resolve(cwd, src);
|
|
363
|
+
const ext = path.extname(p).slice(1).toLowerCase();
|
|
364
|
+
if (ext !== 'pdf') throw new CliError(`${where}: unsupported pdf extension ".${ext}" — use a local .pdf file or an http(s) PDF URL.`);
|
|
365
|
+
let stat;
|
|
366
|
+
try {
|
|
367
|
+
stat = fs.statSync(p);
|
|
368
|
+
} catch {
|
|
369
|
+
throw new CliError(`${where}: cannot read pdf "${src}" (resolved: ${p})`);
|
|
370
|
+
}
|
|
371
|
+
if (stat.size > PDF_MAX_BYTES) {
|
|
372
|
+
throw new CliError(`${where}: pdf "${src}" is ${(stat.size / 1024 / 1024).toFixed(0)}MB — max ${PDF_MAX_BYTES / 1024 / 1024}MB.`);
|
|
373
|
+
}
|
|
374
|
+
block.file = p;
|
|
375
|
+
block.mime = PDF_MIME;
|
|
376
|
+
if (!block.title) block.title = path.basename(p);
|
|
377
|
+
return block;
|
|
378
|
+
}
|
|
379
|
+
|
|
346
380
|
if (type === 'chart') {
|
|
347
381
|
const hasConfig = rawBlock.config && typeof rawBlock.config === 'object' && !Array.isArray(rawBlock.config);
|
|
348
382
|
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);
|