pmx-canvas 0.1.6 → 0.1.8
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/CHANGELOG.md +100 -1
- package/dist/types/json-render/server.d.ts +2 -1
- package/dist/types/server/server.d.ts +1 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +27 -4
- package/skills/pmx-canvas-testing/SKILL.md +15 -1
- package/src/cli/agent.ts +8 -8
- package/src/json-render/server.ts +31 -3
- package/src/mcp/server.ts +24 -13
- package/src/server/canvas-operations.ts +2 -1
- package/src/server/canvas-schema.ts +3 -3
- package/src/server/server.ts +12 -8
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,106 @@
|
|
|
3
3
|
All notable changes to `pmx-canvas` are documented here. This project follows
|
|
4
4
|
[Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [0.1.8] - 2026-04-25
|
|
7
|
+
|
|
8
|
+
Retest-driven follow-up to 0.1.7. This next release restores compatibility
|
|
9
|
+
for image and json-render node creation, fixes the counter MCP fixture's
|
|
10
|
+
accidental iframe overflow, and syncs packaged testing guidance with the
|
|
11
|
+
canonical repo skill.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **Image `path` is accepted as a compatibility alias for `content`.**
|
|
16
|
+
`pmx-canvas node add --type image --path <file>`, HTTP
|
|
17
|
+
`/api/canvas/node` payloads with `{ type: "image", path }`, and MCP
|
|
18
|
+
`canvas_add_node({ type: "image", path })` now all validate the file
|
|
19
|
+
and populate `data.src` instead of creating an empty/broken image node.
|
|
20
|
+
- **json-render creation keeps 0.1.7 compatibility.**
|
|
21
|
+
`canvas_add_json_render_node`, `POST /api/canvas/json-render`, and
|
|
22
|
+
CLI `node add --type json-render` accept omitted titles and infer a
|
|
23
|
+
node title from the root element. Bare legacy component specs like
|
|
24
|
+
`{ type: "Badge", props: {...} }` are wrapped into a one-element
|
|
25
|
+
document before validation, while complete `{ root, elements }` specs
|
|
26
|
+
remain the canonical shape.
|
|
27
|
+
- **Counter MCP fixture no longer creates a scrollable iframe by
|
|
28
|
+
accident.** The fixture now uses border-box sizing and hides root
|
|
29
|
+
overflow so `100vh` layouts with padding do not become `100vh +
|
|
30
|
+
padding`. This removes the observed repeated downward scroll in the
|
|
31
|
+
counter app and adds browser coverage for zero iframe body overflow.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- **Packaged PMX Canvas testing skill is back in sync with canonical
|
|
36
|
+
guidance.** It now documents that `test:coverage` covers only the Bun
|
|
37
|
+
unit suite, names the coverage artifact, and calls out the WebView
|
|
38
|
+
automation timeout caveat used to distinguish environment limits from
|
|
39
|
+
product regressions.
|
|
40
|
+
- **Schema metadata for the `path` alias and relaxed json-render contract
|
|
41
|
+
is version-stable.** `canvas_describe_schema` and `canvas://schema`
|
|
42
|
+
surface the image `path` alias and `title.required: false` on
|
|
43
|
+
json-render so agents discover the new shapes without reading the
|
|
44
|
+
CHANGELOG.
|
|
45
|
+
- **Agent-facing `skills/pmx-canvas/SKILL.md` documents the same
|
|
46
|
+
contract.** The skill now describes the `path` alias for image nodes
|
|
47
|
+
and the relaxed json-render contract (omitted titles, bare component
|
|
48
|
+
specs) so agents do not need to retry across shapes.
|
|
49
|
+
|
|
50
|
+
### Internal
|
|
51
|
+
|
|
52
|
+
- Regression coverage for image `path` alias handling across CLI/HTTP/MCP,
|
|
53
|
+
json-render compatibility for omitted titles plus bare component specs,
|
|
54
|
+
and hosted counter MCP app iframe overflow.
|
|
55
|
+
|
|
56
|
+
## [0.1.7] - 2026-04-26
|
|
57
|
+
|
|
58
|
+
Small retest-driven follow-up to 0.1.6. Three agent-facing ergonomics:
|
|
59
|
+
`canvas_evaluate` now accepts top-level `await`, snapshot responses gain
|
|
60
|
+
a flat `id` alias for add-style consistency, and the PMX Canvas skill
|
|
61
|
+
documents real DOM selectors plus several quirks an agent would
|
|
62
|
+
otherwise have to discover by trial and error.
|
|
63
|
+
|
|
64
|
+
### Added
|
|
65
|
+
|
|
66
|
+
- **Snapshot save responses include a flat `id` alias.** Both
|
|
67
|
+
`canvas_snapshot` and `POST /api/canvas/snapshots` still return the
|
|
68
|
+
nested `snapshot` object, and now also include `id: snapshot.id` at
|
|
69
|
+
the top level — same shape as every other add-style response in the
|
|
70
|
+
canvas API. HTTP and MCP surfaces are aligned.
|
|
71
|
+
|
|
72
|
+
### Changed
|
|
73
|
+
|
|
74
|
+
- **`canvas_evaluate` script mode supports top-level `await`.** Both
|
|
75
|
+
MCP and HTTP WebView script mode wrap multi-statement bodies in an
|
|
76
|
+
async IIFE and serialize the resolved return value, so an agent can
|
|
77
|
+
write `const r = await fetch(...); return r.json();` directly without
|
|
78
|
+
scaffolding the wrapper itself. WebView script documentation now
|
|
79
|
+
describes the async behavior explicitly.
|
|
80
|
+
- **PMX Canvas skill docs now ship a defensive ID extractor pattern.**
|
|
81
|
+
The skill recommends `r.id ?? r.nodeId ?? r.snapshot?.id` so agents
|
|
82
|
+
pull the right id field across add-style, web-artifact, and snapshot
|
|
83
|
+
responses without branching per tool.
|
|
84
|
+
- **PMX Canvas skill docs name the real WebView CSS selectors.** The
|
|
85
|
+
bundled skill calls out `.canvas-node`, `.hud-layer`,
|
|
86
|
+
`.canvas-toolbar`, `.connection-dot`, and related classes, and is
|
|
87
|
+
explicit that nodes do **not** expose stable `data-node-id`
|
|
88
|
+
attributes — agents driving the canvas via `canvas_evaluate` no
|
|
89
|
+
longer have to discover selectors by trial and error.
|
|
90
|
+
- **PMX Canvas skill edge docs list the valid edge types.** `flow`,
|
|
91
|
+
`depends-on`, `relation`, `references` — same as the rest of the
|
|
92
|
+
surface but now explicit in the skill so the agent doesn't guess.
|
|
93
|
+
- **PMX Canvas skill diagram docs clarify
|
|
94
|
+
`canvas_add_diagram.elements`.** The field expects Excalidraw element
|
|
95
|
+
objects (rectangles, ellipses, arrows with bindings, labels), not
|
|
96
|
+
Mermaid / DOT / Graphviz source text or any other diagram DSL.
|
|
97
|
+
|
|
98
|
+
### Internal
|
|
99
|
+
|
|
100
|
+
- Regression coverage for snapshot flat-`id` aliases on both MCP and
|
|
101
|
+
HTTP surfaces, plus async / top-level-`await` WebView script bodies.
|
|
102
|
+
|
|
103
|
+
[0.1.8]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.1.8
|
|
104
|
+
[0.1.7]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.1.7
|
|
105
|
+
|
|
6
106
|
## [0.1.6] - 2026-04-26
|
|
7
107
|
|
|
8
108
|
CLI/MCP regression cleanup after the 0.1.5 coverage pass. This release tightens
|
|
@@ -50,7 +150,6 @@ drift without guessing.
|
|
|
50
150
|
omitted from the response instead of being `undefined`. Consumers can now
|
|
51
151
|
reliably use `'id' in response` to detect the build-only case. `nodeId` is
|
|
52
152
|
always present and remains the canonical identifier.
|
|
53
|
-
|
|
54
153
|
### Fixed
|
|
55
154
|
|
|
56
155
|
- **`pmx-canvas graph add` no longer starts a rogue server.** The top-level
|
|
@@ -4,7 +4,7 @@ export interface JsonRenderSpec {
|
|
|
4
4
|
state?: Record<string, unknown>;
|
|
5
5
|
}
|
|
6
6
|
export interface JsonRenderNodeInput {
|
|
7
|
-
title
|
|
7
|
+
title?: string;
|
|
8
8
|
spec: unknown;
|
|
9
9
|
x?: number;
|
|
10
10
|
y?: number;
|
|
@@ -44,6 +44,7 @@ export declare const GRAPH_NODE_SIZE: {
|
|
|
44
44
|
height: number;
|
|
45
45
|
};
|
|
46
46
|
export type GraphChartType = 'LineChart' | 'BarChart' | 'PieChart' | 'AreaChart' | 'ScatterChart' | 'RadarChart' | 'StackedBarChart' | 'ComposedChart';
|
|
47
|
+
export declare function inferJsonRenderNodeTitle(spec: JsonRenderSpec, fallback?: string): string;
|
|
47
48
|
export declare function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpec;
|
|
48
49
|
export declare function normalizeGraphType(value: string): GraphChartType;
|
|
49
50
|
export declare function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec;
|
|
@@ -60,6 +60,7 @@ export declare function getCanvasAutomationWebViewStatus(): CanvasAutomationWebV
|
|
|
60
60
|
export declare function stopCanvasAutomationWebView(): Promise<boolean>;
|
|
61
61
|
export declare function startCanvasAutomationWebView(url: string, options?: CanvasAutomationWebViewOptions): Promise<CanvasAutomationWebViewStatus>;
|
|
62
62
|
export declare function evaluateCanvasAutomationWebView(expression: string): Promise<unknown>;
|
|
63
|
+
export declare function wrapCanvasAutomationScript(script: string): string;
|
|
63
64
|
export declare function resizeCanvasAutomationWebView(width: number, height: number): Promise<CanvasAutomationWebViewStatus>;
|
|
64
65
|
export declare function screenshotCanvasAutomationWebView(options?: Record<string, unknown>): Promise<Uint8Array>;
|
|
65
66
|
export interface PrimaryWorkbenchIntent {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/server/index.ts",
|
|
@@ -270,6 +270,7 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
|
|
|
270
270
|
- `content`: for most types, this is markdown text. For `file` type, pass the **file path**
|
|
271
271
|
(e.g., `"src/auth/login.ts"`) — the server auto-loads the file content and watches for changes.
|
|
272
272
|
For `image` type, pass a file path, URL, or data URI.
|
|
273
|
+
- `path`: compatibility alias for image paths only; prefer `content` for new image calls
|
|
273
274
|
- `x`, `y`: position (auto-placed if omitted — prefer omitting for auto-layout)
|
|
274
275
|
- `width`, `height`: dimensions (sensible defaults provided)
|
|
275
276
|
- `color`: semantic color
|
|
@@ -306,8 +307,9 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
|
|
|
306
307
|
```
|
|
307
308
|
|
|
308
309
|
**`canvas_add_json_render_node`** — Add a native json-render node
|
|
309
|
-
- Required: `
|
|
310
|
-
-
|
|
310
|
+
- Required: `spec`; `title` is optional and inferred from the root element when omitted
|
|
311
|
+
- Prefer a complete json-render object with `root`, `elements`, and optional `state`
|
|
312
|
+
- Legacy bare component specs like `{ type: "Badge", props: {...} }` are accepted and wrapped into a one-element document for compatibility
|
|
311
313
|
- Use this when you want a structured UI panel rendered directly inside PMX Canvas
|
|
312
314
|
- For shadcn `Badge`, prefer `props.text` with variants `default`, `secondary`, `destructive`, or
|
|
313
315
|
`outline`. Legacy `props.label` and status variants (`success`, `info`, `warning`, `error`,
|
|
@@ -332,6 +334,10 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
|
|
|
332
334
|
- Cold builds commonly take 45-60 seconds; use a long client timeout such as 300000 ms or more
|
|
333
335
|
- Returns both `id` and `nodeId` for the created artifact node when `openInCanvas` is true
|
|
334
336
|
|
|
337
|
+
ID extraction for mixed tool responses:
|
|
338
|
+
- Most add-style tools return a flat `id`; web artifacts return `id` plus `nodeId`; snapshots return `id` plus nested `snapshot.id`.
|
|
339
|
+
- Defensive extractor: `const getId = (r) => r.id ?? r.nodeId ?? r.snapshot?.id;`
|
|
340
|
+
|
|
335
341
|
**`canvas_open_mcp_app`** — Open a tool-backed external MCP app node
|
|
336
342
|
- Required: `toolName`, `transport`
|
|
337
343
|
- `transport` is either `{ type: "stdio", command, args?, cwd?, env? }` or `{ type: "http", url, headers? }`
|
|
@@ -366,7 +372,7 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
|
|
|
366
372
|
- `from`, `to` (required): source and target node IDs
|
|
367
373
|
- `fromSearch`, `toSearch`: optional search-based selectors when you do not have IDs. Each search
|
|
368
374
|
query must resolve to exactly one node or the edge creation fails with an ambiguity error.
|
|
369
|
-
- `type`:
|
|
375
|
+
- `type`: `flow`, `depends-on`, `relation`, or `references` (default: `relation`)
|
|
370
376
|
- `label`: descriptive relationship label
|
|
371
377
|
- `style`: `solid`, `dashed`, or `dotted`
|
|
372
378
|
- `animated`: boolean for visual emphasis
|
|
@@ -465,6 +471,7 @@ Current product caveats for grouped comparison boards:
|
|
|
465
471
|
**`canvas_redo`** — Redo the last undone mutation
|
|
466
472
|
**`canvas_snapshot`** — Save a named snapshot to disk
|
|
467
473
|
- `name` (required): descriptive snapshot name (e.g., "before-refactor")
|
|
474
|
+
- Returns `{ ok, id, snapshot }`; the flat `id` is an alias for `snapshot.id`
|
|
468
475
|
**`canvas_restore`** — Restore canvas from a saved snapshot
|
|
469
476
|
- `id`: snapshot to restore
|
|
470
477
|
**`canvas_diff`** — Compare current canvas against a saved snapshot
|
|
@@ -502,7 +509,8 @@ tools below operate on the live canvas state.
|
|
|
502
509
|
**`canvas_webview_stop`** — Tear down the automation session
|
|
503
510
|
|
|
504
511
|
**`canvas_evaluate`** — Run JavaScript inside the workbench page and return the result
|
|
505
|
-
- Required: `expression` (
|
|
512
|
+
- Required: exactly one of `expression` (single JS expression) or `script` (multi-statement body)
|
|
513
|
+
- `script` is wrapped in an async IIFE, so top-level `await` works inside script bodies
|
|
506
514
|
- Useful for asserting DOM state after a sequence of canvas mutations
|
|
507
515
|
- Example: read the count of rendered `.canvas-node` elements:
|
|
508
516
|
|
|
@@ -510,6 +518,20 @@ tools below operate on the live canvas state.
|
|
|
510
518
|
canvas_evaluate({ expression: 'document.querySelectorAll(".canvas-node").length' })
|
|
511
519
|
```
|
|
512
520
|
|
|
521
|
+
Useful workbench selectors:
|
|
522
|
+
- Nodes: `.canvas-node`, `.canvas-node.active`, `.canvas-node.context-pinned`, `.canvas-node.group-node`
|
|
523
|
+
- Node internals: `.node-title`, `.node-titlebar`, `.node-body`, `.node-type-badge`, `.node-controls`
|
|
524
|
+
- Canvas chrome: `.hud-layer`, `.canvas-toolbar`, `.connection-dot`, `.canvas-bootstrap-card`
|
|
525
|
+
- Nodes do not expose stable `data-node-id` attributes. Use `canvas_get_layout`, `canvas_search`, or MCP resource data for exact node IDs.
|
|
526
|
+
|
|
527
|
+
Async script example:
|
|
528
|
+
|
|
529
|
+
```typescript
|
|
530
|
+
canvas_evaluate({
|
|
531
|
+
script: 'const title = await Promise.resolve(document.title); return title;',
|
|
532
|
+
})
|
|
533
|
+
```
|
|
534
|
+
|
|
513
535
|
**`canvas_resize`** — Change the WebView viewport
|
|
514
536
|
- Required: `width`, `height`
|
|
515
537
|
- Use before `canvas_screenshot` when the human needs a specific aspect ratio
|
|
@@ -535,6 +557,7 @@ canvas_webview_stop();
|
|
|
535
557
|
[Excalidraw MCP app](https://github.com/excalidraw/excalidraw-mcp)
|
|
536
558
|
- Required: `elements` — an array of Excalidraw elements (rectangles, ellipses, diamonds, arrows,
|
|
537
559
|
text). Can also be a JSON-array string.
|
|
560
|
+
- `elements` must be Excalidraw element objects, not Mermaid/DOT/source-text diagrams. Convert source diagrams to Excalidraw elements first or use a markdown/web-artifact node.
|
|
538
561
|
- Optional: `title`, `x`, `y`, `width`, `height`
|
|
539
562
|
- The diagram opens inside an `mcp-app` node with fullscreen editing and draw-on animations
|
|
540
563
|
- CLI equivalent: `pmx-canvas external-app add --kind excalidraw --title "Diagram"`
|
|
@@ -40,11 +40,25 @@ bun run test:all # Bun suite + browser smoke
|
|
|
40
40
|
Manual browser validation also requires a fresh client bundle. `bun run test:web-canvas`
|
|
41
41
|
already does this for you.
|
|
42
42
|
|
|
43
|
+
## Coverage Notes
|
|
44
|
+
|
|
45
|
+
- `bun run test:coverage` covers the Bun unit suite under `tests/unit/`
|
|
46
|
+
- Coverage output is written to `coverage/lcov.info` and also printed as a text summary
|
|
47
|
+
- CI currently uses that same unit-test coverage command, then runs browser smoke separately
|
|
48
|
+
- Do not describe `test:coverage` as full-stack coverage; Playwright coverage is not wired in here
|
|
49
|
+
|
|
43
50
|
## Current Project Test Surface
|
|
44
51
|
|
|
45
52
|
- Bun tests live under `tests/unit/`
|
|
46
53
|
- Playwright browser smoke lives under `tests/e2e/`
|
|
47
|
-
- CI runs coverage plus the browser smoke flow
|
|
54
|
+
- CI runs Bun coverage plus the browser smoke flow
|
|
55
|
+
|
|
56
|
+
## WebView Automation Caveat
|
|
57
|
+
|
|
58
|
+
- Some Linux/CI environments expose `Bun.WebView` but still cannot start a usable automation
|
|
59
|
+
session within the timeout window
|
|
60
|
+
- When testing WebView automation, treat a cleanly reported unsupported/timeout runtime boundary
|
|
61
|
+
as distinct from a product regression
|
|
48
62
|
|
|
49
63
|
Prefer extending the existing suites before inventing a one-off script.
|
|
50
64
|
|
package/src/cli/agent.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
-
import { openUrlInExternalBrowser } from '../server/server.js';
|
|
15
|
+
import { openUrlInExternalBrowser, wrapCanvasAutomationScript } from '../server/server.js';
|
|
16
16
|
import { DEFAULT_EXCALIDRAW_ELEMENTS } from '../server/diagram-presets.js';
|
|
17
17
|
import {
|
|
18
18
|
ALL_SEMANTIC_WATCH_EVENT_TYPES,
|
|
@@ -522,11 +522,8 @@ async function buildJsonRenderRequestBody(
|
|
|
522
522
|
flags: Record<string, string | true>,
|
|
523
523
|
): Promise<Record<string, unknown>> {
|
|
524
524
|
const hint =
|
|
525
|
-
'Use: pmx-canvas node add --type json-render --
|
|
525
|
+
'Use: pmx-canvas node add --type json-render --spec-file ./dashboard.json --title "Ops Dashboard"';
|
|
526
526
|
const title = typeof flags.title === 'string' ? flags.title.trim() : '';
|
|
527
|
-
if (!title) {
|
|
528
|
-
die('json-render nodes require --title.', hint);
|
|
529
|
-
}
|
|
530
527
|
|
|
531
528
|
const rawSpec = await readTextInput(flags, {
|
|
532
529
|
fileFlags: ['spec-file'],
|
|
@@ -538,7 +535,7 @@ async function buildJsonRenderRequestBody(
|
|
|
538
535
|
});
|
|
539
536
|
|
|
540
537
|
const spec = parseJsonValue(rawSpec, 'JSON spec', hint);
|
|
541
|
-
const body: Record<string, unknown> = { title, spec };
|
|
538
|
+
const body: Record<string, unknown> = { ...(title ? { title } : {}), spec };
|
|
542
539
|
applyCommonGeometryFlags(body, flags, {
|
|
543
540
|
x: 'Use a finite number, e.g. --x 500',
|
|
544
541
|
y: 'Use a finite number, e.g. --y 300',
|
|
@@ -985,8 +982,11 @@ cmd('node add', 'Add a node to the canvas', [
|
|
|
985
982
|
const body: Record<string, unknown> = { type };
|
|
986
983
|
if (flags.title) body.title = flags.title;
|
|
987
984
|
const webpageUrl = getStringFlag(flags, 'url');
|
|
985
|
+
const imagePath = getStringFlag(flags, 'path');
|
|
988
986
|
if (type === 'webpage' && webpageUrl) {
|
|
989
987
|
body.url = webpageUrl;
|
|
988
|
+
} else if (type === 'image' && imagePath && !flags.content) {
|
|
989
|
+
body.content = imagePath;
|
|
990
990
|
} else if (flags.content) {
|
|
991
991
|
body.content = flags.content;
|
|
992
992
|
}
|
|
@@ -1856,9 +1856,9 @@ cmd('webview evaluate', 'Evaluate JavaScript in the active Bun.WebView automatio
|
|
|
1856
1856
|
'pmx-canvas webview evaluate --file ./probe.js',
|
|
1857
1857
|
);
|
|
1858
1858
|
}
|
|
1859
|
-
expression =
|
|
1859
|
+
expression = wrapCanvasAutomationScript(script);
|
|
1860
1860
|
} else if (typeof flags.script === 'string') {
|
|
1861
|
-
expression =
|
|
1861
|
+
expression = wrapCanvasAutomationScript(flags.script);
|
|
1862
1862
|
} else {
|
|
1863
1863
|
expression = requireFlag(
|
|
1864
1864
|
flags,
|
|
@@ -11,7 +11,7 @@ export interface JsonRenderSpec {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export interface JsonRenderNodeInput {
|
|
14
|
-
title
|
|
14
|
+
title?: string;
|
|
15
15
|
spec: unknown;
|
|
16
16
|
x?: number;
|
|
17
17
|
y?: number;
|
|
@@ -447,10 +447,38 @@ function normalizeSpec(spec: Record<string, unknown>): Record<string, unknown> {
|
|
|
447
447
|
return changed ? { ...spec, elements: normalizedElements } : spec;
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
-
|
|
450
|
+
function isBareJsonRenderElement(spec: Record<string, unknown>): boolean {
|
|
451
|
+
return typeof spec.type === 'string' && !('root' in spec) && !('elements' in spec);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function normalizeJsonRenderInput(spec: unknown): unknown {
|
|
451
455
|
const specRecord = asRecord(spec);
|
|
456
|
+
if (!specRecord || !isBareJsonRenderElement(specRecord)) return spec;
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
root: 'root',
|
|
460
|
+
elements: {
|
|
461
|
+
root: {
|
|
462
|
+
...specRecord,
|
|
463
|
+
children: Array.isArray(specRecord.children)
|
|
464
|
+
? specRecord.children.filter((child: unknown) => typeof child === 'string')
|
|
465
|
+
: [],
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function inferJsonRenderNodeTitle(spec: JsonRenderSpec, fallback = 'json-render'): string {
|
|
472
|
+
const rootElement = asRecord(spec.elements[spec.root]);
|
|
473
|
+
const rootProps = asRecord(rootElement?.props);
|
|
474
|
+
const title = rootProps?.title ?? rootProps?.text ?? rootElement?.type;
|
|
475
|
+
return typeof title === 'string' && title.trim().length > 0 ? title.trim() : fallback;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpec {
|
|
479
|
+
const specRecord = asRecord(normalizeJsonRenderInput(spec));
|
|
452
480
|
if (!specRecord || typeof specRecord.root !== 'string' || !asRecord(specRecord.elements)) {
|
|
453
|
-
throw new Error('Missing root and elements in spec.');
|
|
481
|
+
throw new Error('Missing root and elements in spec. Pass a complete {root, elements} document, or a single bare component object with a type field.');
|
|
454
482
|
}
|
|
455
483
|
|
|
456
484
|
const normalizedSpec = normalizeSpec(specRecord);
|
package/src/mcp/server.ts
CHANGED
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
type PmxCanvas,
|
|
33
33
|
} from '../server/index.js';
|
|
34
34
|
import { serializeNodeForAgentContext } from '../server/agent-context.js';
|
|
35
|
-
import { emitPrimaryWorkbenchEvent } from '../server/server.js';
|
|
35
|
+
import { emitPrimaryWorkbenchEvent, wrapCanvasAutomationScript } from '../server/server.js';
|
|
36
36
|
import { searchNodes, buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
|
|
37
37
|
import { mutationHistory, diffLayouts, formatDiff } from '../server/mutation-history.js';
|
|
38
38
|
import { buildCodeGraphSummary, formatCodeGraph } from '../server/code-graph.js';
|
|
@@ -41,11 +41,18 @@ import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js
|
|
|
41
41
|
|
|
42
42
|
let canvas: PmxCanvas | null = null;
|
|
43
43
|
|
|
44
|
-
const jsonRenderSpecSchema = z.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
const jsonRenderSpecSchema = z.union([
|
|
45
|
+
z.object({
|
|
46
|
+
root: z.string(),
|
|
47
|
+
elements: z.record(z.string(), z.unknown()),
|
|
48
|
+
state: z.record(z.string(), z.unknown()).optional(),
|
|
49
|
+
}).passthrough(),
|
|
50
|
+
z.object({
|
|
51
|
+
type: z.string(),
|
|
52
|
+
props: z.record(z.string(), z.unknown()).optional(),
|
|
53
|
+
children: z.array(z.string()).optional(),
|
|
54
|
+
}).passthrough(),
|
|
55
|
+
]);
|
|
49
56
|
|
|
50
57
|
function structuredSchemaDescription(): string {
|
|
51
58
|
const routing = describeCanvasSchema().mcp.nodeTypeRouting;
|
|
@@ -140,6 +147,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
140
147
|
.describe('Node type (prefer canvas_create_group for groups)'),
|
|
141
148
|
title: z.string().optional().describe('Node title'),
|
|
142
149
|
content: z.string().optional().describe('Node content (markdown for markdown nodes, file path for file nodes, image path/URL/data-URI for image nodes, URL for webpage nodes)'),
|
|
150
|
+
path: z.string().optional().describe('Compatibility alias for image node content. Prefer content for image paths.'),
|
|
143
151
|
url: z.string().optional().describe('Canonical webpage URL field for webpage nodes. Overrides content when both are provided.'),
|
|
144
152
|
x: z.number().optional().describe('X position (auto-placed if omitted)'),
|
|
145
153
|
y: z.number().optional().describe('Y position (auto-placed if omitted)'),
|
|
@@ -169,7 +177,10 @@ export async function startMcpServer(): Promise<void> {
|
|
|
169
177
|
...(result.ok ? {} : { isError: true }),
|
|
170
178
|
};
|
|
171
179
|
}
|
|
172
|
-
const
|
|
180
|
+
const nodeInput = input.type === 'image' && input.path && !input.content
|
|
181
|
+
? { ...input, content: input.path }
|
|
182
|
+
: input;
|
|
183
|
+
const id = c.addNode(nodeInput);
|
|
173
184
|
return {
|
|
174
185
|
content: [{ type: 'text', text: JSON.stringify(createdNodePayload(c, id), null, 2) }],
|
|
175
186
|
};
|
|
@@ -442,8 +453,8 @@ export async function startMcpServer(): Promise<void> {
|
|
|
442
453
|
'canvas_add_json_render_node',
|
|
443
454
|
'Create a native json-render canvas node from a complete spec. Use this for structured dashboards, forms, tables, and other interactive UI panels that should render directly inside PMX Canvas.',
|
|
444
455
|
{
|
|
445
|
-
title: z.string().describe('
|
|
446
|
-
spec:
|
|
456
|
+
title: z.string().optional().describe('Optional node title. If omitted, PMX Canvas infers one from the root element.'),
|
|
457
|
+
spec: z.unknown().describe('json-render spec. Prefer a complete {root, elements, state?} document; a single bare component object is accepted for legacy callers.'),
|
|
447
458
|
x: z.number().optional().describe('Optional X position'),
|
|
448
459
|
y: z.number().optional().describe('Optional Y position'),
|
|
449
460
|
width: z.number().optional().describe('Optional node width'),
|
|
@@ -453,7 +464,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
453
464
|
const c = await ensureCanvas();
|
|
454
465
|
try {
|
|
455
466
|
const result = c.addJsonRenderNode({
|
|
456
|
-
title: input.title,
|
|
467
|
+
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
|
457
468
|
spec: input.spec,
|
|
458
469
|
...(typeof input.x === 'number' ? { x: input.x } : {}),
|
|
459
470
|
...(typeof input.y === 'number' ? { y: input.y } : {}),
|
|
@@ -899,7 +910,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
899
910
|
'Evaluate JavaScript in the active Bun.WebView automation session for the workbench page. Use this to inspect rendered browser state. Requires an active automation session started via canvas_webview_start.',
|
|
900
911
|
{
|
|
901
912
|
expression: z.string().optional().describe('JavaScript expression to evaluate in the page context'),
|
|
902
|
-
script: z.string().optional().describe('Multi-statement JavaScript body. The MCP server wraps it in an IIFE and evaluates the return value.'),
|
|
913
|
+
script: z.string().optional().describe('Multi-statement JavaScript body. The MCP server wraps it in an async IIFE and evaluates the resolved return value.'),
|
|
903
914
|
},
|
|
904
915
|
async ({ expression, script }) => {
|
|
905
916
|
const c = await ensureCanvas();
|
|
@@ -910,7 +921,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
910
921
|
};
|
|
911
922
|
}
|
|
912
923
|
|
|
913
|
-
const source = script ?
|
|
924
|
+
const source = script ? wrapCanvasAutomationScript(script) : expression!;
|
|
914
925
|
try {
|
|
915
926
|
const value = await c.evaluateAutomationWebView(source);
|
|
916
927
|
return {
|
|
@@ -1401,7 +1412,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
1401
1412
|
if (!snapshot) {
|
|
1402
1413
|
return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'Failed to save snapshot' }) }] };
|
|
1403
1414
|
}
|
|
1404
|
-
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, snapshot }) }] };
|
|
1415
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, id: snapshot.id, snapshot }) }] };
|
|
1405
1416
|
},
|
|
1406
1417
|
);
|
|
1407
1418
|
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
buildGraphSpec,
|
|
25
25
|
createJsonRenderNodeData,
|
|
26
26
|
GRAPH_NODE_SIZE,
|
|
27
|
+
inferJsonRenderNodeTitle,
|
|
27
28
|
JSON_RENDER_NODE_SIZE,
|
|
28
29
|
normalizeAndValidateJsonRenderSpec,
|
|
29
30
|
type GraphNodeInput,
|
|
@@ -1000,7 +1001,7 @@ export function createCanvasJsonRenderNode(
|
|
|
1000
1001
|
collapsed: false,
|
|
1001
1002
|
pinned: false,
|
|
1002
1003
|
dockPosition: null,
|
|
1003
|
-
data: createJsonRenderNodeData(id, input.title, spec, {
|
|
1004
|
+
data: createJsonRenderNodeData(id, input.title?.trim() || inferJsonRenderNodeTitle(spec), spec, {
|
|
1004
1005
|
viewerType: 'json-render',
|
|
1005
1006
|
}),
|
|
1006
1007
|
};
|
|
@@ -171,7 +171,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
171
171
|
endpoint: '/api/canvas/node',
|
|
172
172
|
mcpTool: 'canvas_add_node',
|
|
173
173
|
fields: [
|
|
174
|
-
{ name: 'content', type: 'string', required: true, description: 'Image path, URL, or data URI.' },
|
|
174
|
+
{ name: 'content', type: 'string', required: true, description: 'Image path, URL, or data URI.', aliases: ['path'] },
|
|
175
175
|
{ name: 'title', type: 'string', required: false, description: 'Optional title override.' },
|
|
176
176
|
{ name: 'data.warning', type: 'string | { title?: string; detail: string }', required: false, description: 'Optional agent-supplied warning shown above the image.' },
|
|
177
177
|
{ name: 'data.warnings', type: 'Array<string | { title?: string; detail: string }>', required: false, description: 'Optional list of agent-supplied image warnings.' },
|
|
@@ -280,8 +280,8 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
280
280
|
endpoint: '/api/canvas/json-render',
|
|
281
281
|
mcpTool: 'canvas_add_json_render_node',
|
|
282
282
|
fields: [
|
|
283
|
-
{ name: 'title', type: 'string', required:
|
|
284
|
-
{ name: 'spec', type: 'JsonRenderSpec', required: true, description: 'Complete json-render spec.' },
|
|
283
|
+
{ name: 'title', type: 'string', required: false, description: 'Optional rendered node title; inferred from the root element when omitted.' },
|
|
284
|
+
{ name: 'spec', type: 'JsonRenderSpec | JsonRenderElement', required: true, description: 'Complete {root, elements} json-render spec, or a legacy single bare component object with a type field.' },
|
|
285
285
|
{ name: 'x', type: 'number', required: false, description: 'Optional X position.' },
|
|
286
286
|
{ name: 'y', type: 'number', required: false, description: 'Optional Y position.' },
|
|
287
287
|
{ name: 'width', type: 'number', required: false, description: 'Optional node width.' },
|
package/src/server/server.ts
CHANGED
|
@@ -473,6 +473,10 @@ export async function evaluateCanvasAutomationWebView(expression: string): Promi
|
|
|
473
473
|
));
|
|
474
474
|
}
|
|
475
475
|
|
|
476
|
+
export function wrapCanvasAutomationScript(script: string): string {
|
|
477
|
+
return `(async () => {\n${script}\n})()`;
|
|
478
|
+
}
|
|
479
|
+
|
|
476
480
|
export async function resizeCanvasAutomationWebView(
|
|
477
481
|
width: number,
|
|
478
482
|
height: number,
|
|
@@ -1206,12 +1210,15 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
|
|
|
1206
1210
|
const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
|
|
1207
1211
|
? body.data as Record<string, unknown>
|
|
1208
1212
|
: undefined;
|
|
1213
|
+
const content = type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string'
|
|
1214
|
+
? body.path
|
|
1215
|
+
: body.content;
|
|
1209
1216
|
let added: ReturnType<typeof addCanvasNode>;
|
|
1210
1217
|
try {
|
|
1211
1218
|
added = addCanvasNode({
|
|
1212
1219
|
type: type as CanvasNodeState['type'],
|
|
1213
1220
|
...(typeof body.title === 'string' ? { title: body.title } : {}),
|
|
1214
|
-
...(typeof
|
|
1221
|
+
...(typeof content === 'string' ? { content } : {}),
|
|
1215
1222
|
...(extraData ? { data: extraData } : {}),
|
|
1216
1223
|
...(typeof body.x === 'number' ? { x: body.x } : {}),
|
|
1217
1224
|
...(typeof body.y === 'number' ? { y: body.y } : {}),
|
|
@@ -1599,13 +1606,10 @@ async function handleCanvasAddJsonRender(req: Request): Promise<Response> {
|
|
|
1599
1606
|
const title = typeof body.title === 'string' ? body.title.trim() : '';
|
|
1600
1607
|
const rawSpec =
|
|
1601
1608
|
body.spec && typeof body.spec === 'object' && !Array.isArray(body.spec) ? body.spec : body;
|
|
1602
|
-
if (!title) {
|
|
1603
|
-
return responseJson({ ok: false, error: 'Missing required field: title.' }, 400);
|
|
1604
|
-
}
|
|
1605
1609
|
|
|
1606
1610
|
try {
|
|
1607
1611
|
const result = createCanvasJsonRenderNode({
|
|
1608
|
-
title,
|
|
1612
|
+
...(title ? { title } : {}),
|
|
1609
1613
|
spec: rawSpec,
|
|
1610
1614
|
...(typeof body.x === 'number' ? { x: body.x } : {}),
|
|
1611
1615
|
...(typeof body.y === 'number' ? { y: body.y } : {}),
|
|
@@ -2389,10 +2393,10 @@ async function handleWorkbenchWebViewEvaluate(req: Request): Promise<Response> {
|
|
|
2389
2393
|
if ((expression ? 1 : 0) + (script ? 1 : 0) !== 1) {
|
|
2390
2394
|
return responseJson({
|
|
2391
2395
|
ok: false,
|
|
2392
|
-
error: 'Pass exactly one of "expression" (single JS expression) or "script" (multi-statement body, wrapped in an IIFE).',
|
|
2396
|
+
error: 'Pass exactly one of "expression" (single JS expression) or "script" (multi-statement body, wrapped in an async IIFE).',
|
|
2393
2397
|
}, 400);
|
|
2394
2398
|
}
|
|
2395
|
-
const source = script ?
|
|
2399
|
+
const source = script ? wrapCanvasAutomationScript(script) : expression;
|
|
2396
2400
|
|
|
2397
2401
|
try {
|
|
2398
2402
|
const value = await evaluateCanvasAutomationWebView(source);
|
|
@@ -2899,7 +2903,7 @@ async function handleSnapshotSave(req: Request): Promise<Response> {
|
|
|
2899
2903
|
if (!name) return responseText('Missing snapshot name', 400);
|
|
2900
2904
|
const snapshot = saveCanvasSnapshot(name);
|
|
2901
2905
|
if (!snapshot) return responseText('Failed to save snapshot', 500);
|
|
2902
|
-
return responseJson({ ok: true, snapshot });
|
|
2906
|
+
return responseJson({ ok: true, id: snapshot.id, snapshot });
|
|
2903
2907
|
}
|
|
2904
2908
|
|
|
2905
2909
|
async function handleContextPinsUpdate(req: Request): Promise<Response> {
|