pmx-canvas 0.1.7 → 0.1.9
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 +116 -0
- package/Readme.md +52 -54
- package/dist/canvas/index.js +61 -61
- package/dist/types/client/canvas/auto-fit.d.ts +5 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
- package/dist/types/json-render/server.d.ts +3 -1
- package/dist/types/server/canvas-operations.d.ts +49 -0
- package/dist/types/server/index.d.ts +9 -1
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +17 -3
- package/skills/pmx-canvas-testing/SKILL.md +15 -1
- package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +1 -1
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +11 -0
- package/src/cli/agent.ts +114 -19
- package/src/client/canvas/CanvasNode.tsx +5 -7
- package/src/client/canvas/auto-fit.ts +21 -0
- package/src/client/nodes/ExtAppFrame.tsx +4 -2
- package/src/json-render/server.ts +58 -3
- package/src/mcp/server.ts +66 -23
- package/src/server/canvas-operations.ts +311 -21
- package/src/server/canvas-schema.ts +5 -3
- package/src/server/index.ts +45 -6
- package/src/server/server.ts +154 -40
|
@@ -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;
|
|
@@ -430,6 +430,7 @@ function normalizeSpec(spec: Record<string, unknown>): Record<string, unknown> {
|
|
|
430
430
|
const elementChanged =
|
|
431
431
|
resolvedType !== element.type ||
|
|
432
432
|
JSON.stringify(normalizedProps) !== JSON.stringify(rawProps) ||
|
|
433
|
+
!('visible' in element) ||
|
|
433
434
|
!Array.isArray(element.children) ||
|
|
434
435
|
normalizedChildren.length !== element.children.length;
|
|
435
436
|
|
|
@@ -438,6 +439,7 @@ function normalizeSpec(spec: Record<string, unknown>): Record<string, unknown> {
|
|
|
438
439
|
...element,
|
|
439
440
|
type: resolvedType,
|
|
440
441
|
props: normalizedProps,
|
|
442
|
+
visible: 'visible' in element ? element.visible : true,
|
|
441
443
|
children: normalizedChildren,
|
|
442
444
|
}
|
|
443
445
|
: rawElement;
|
|
@@ -447,10 +449,39 @@ function normalizeSpec(spec: Record<string, unknown>): Record<string, unknown> {
|
|
|
447
449
|
return changed ? { ...spec, elements: normalizedElements } : spec;
|
|
448
450
|
}
|
|
449
451
|
|
|
450
|
-
|
|
452
|
+
function isBareJsonRenderElement(spec: Record<string, unknown>): boolean {
|
|
453
|
+
return typeof spec.type === 'string' && !('root' in spec) && !('elements' in spec);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function normalizeJsonRenderInput(spec: unknown): unknown {
|
|
451
457
|
const specRecord = asRecord(spec);
|
|
458
|
+
if (!specRecord || !isBareJsonRenderElement(specRecord)) return spec;
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
root: 'root',
|
|
462
|
+
elements: {
|
|
463
|
+
root: {
|
|
464
|
+
...specRecord,
|
|
465
|
+
visible: 'visible' in specRecord ? specRecord.visible : true,
|
|
466
|
+
children: Array.isArray(specRecord.children)
|
|
467
|
+
? specRecord.children.filter((child: unknown) => typeof child === 'string')
|
|
468
|
+
: [],
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function inferJsonRenderNodeTitle(spec: JsonRenderSpec, fallback = 'json-render'): string {
|
|
475
|
+
const rootElement = asRecord(spec.elements[spec.root]);
|
|
476
|
+
const rootProps = asRecord(rootElement?.props);
|
|
477
|
+
const title = rootProps?.title ?? rootProps?.text ?? rootElement?.type;
|
|
478
|
+
return typeof title === 'string' && title.trim().length > 0 ? title.trim() : fallback;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpec {
|
|
482
|
+
const specRecord = asRecord(normalizeJsonRenderInput(spec));
|
|
452
483
|
if (!specRecord || typeof specRecord.root !== 'string' || !asRecord(specRecord.elements)) {
|
|
453
|
-
throw new Error('Missing root and elements in spec.');
|
|
484
|
+
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
485
|
}
|
|
455
486
|
|
|
456
487
|
const normalizedSpec = normalizeSpec(specRecord);
|
|
@@ -572,6 +603,30 @@ export function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec {
|
|
|
572
603
|
});
|
|
573
604
|
}
|
|
574
605
|
|
|
606
|
+
export function buildGraphConfig(input: GraphNodeInput): Record<string, unknown> {
|
|
607
|
+
const title = input.title?.trim() || 'Graph';
|
|
608
|
+
return {
|
|
609
|
+
title,
|
|
610
|
+
graphType: input.graphType,
|
|
611
|
+
data: input.data,
|
|
612
|
+
...(input.xKey ? { xKey: input.xKey } : {}),
|
|
613
|
+
...(input.yKey ? { yKey: input.yKey } : {}),
|
|
614
|
+
...(input.zKey ? { zKey: input.zKey } : {}),
|
|
615
|
+
...(input.nameKey ? { nameKey: input.nameKey } : {}),
|
|
616
|
+
...(input.valueKey ? { valueKey: input.valueKey } : {}),
|
|
617
|
+
...(input.axisKey ? { axisKey: input.axisKey } : {}),
|
|
618
|
+
...(input.metrics?.length ? { metrics: input.metrics } : {}),
|
|
619
|
+
...(input.series?.length ? { series: input.series } : {}),
|
|
620
|
+
...(input.barKey ? { barKey: input.barKey } : {}),
|
|
621
|
+
...(input.lineKey ? { lineKey: input.lineKey } : {}),
|
|
622
|
+
...(input.aggregate ? { aggregate: input.aggregate } : {}),
|
|
623
|
+
...(input.color ? { color: input.color } : {}),
|
|
624
|
+
...(input.barColor ? { barColor: input.barColor } : {}),
|
|
625
|
+
...(input.lineColor ? { lineColor: input.lineColor } : {}),
|
|
626
|
+
...(typeof input.height === 'number' ? { height: input.height } : {}),
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
575
630
|
export function createJsonRenderNodeData(
|
|
576
631
|
nodeId: string,
|
|
577
632
|
title: string,
|
package/src/mcp/server.ts
CHANGED
|
@@ -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;
|
|
@@ -88,7 +95,9 @@ function encodeBase64(bytes: Uint8Array): string {
|
|
|
88
95
|
|
|
89
96
|
function createdNodePayload(c: PmxCanvas, id: string): Record<string, unknown> {
|
|
90
97
|
const node = c.getNode(id);
|
|
91
|
-
|
|
98
|
+
if (!node) return { ok: true, id };
|
|
99
|
+
const serialized = serializeCanvasNode(node);
|
|
100
|
+
return { ok: true, node: serialized, ...serialized };
|
|
92
101
|
}
|
|
93
102
|
|
|
94
103
|
export async function startMcpServer(): Promise<void> {
|
|
@@ -140,6 +149,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
140
149
|
.describe('Node type (prefer canvas_create_group for groups)'),
|
|
141
150
|
title: z.string().optional().describe('Node title'),
|
|
142
151
|
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)'),
|
|
152
|
+
path: z.string().optional().describe('Compatibility alias for image node content. Prefer content for image paths.'),
|
|
143
153
|
url: z.string().optional().describe('Canonical webpage URL field for webpage nodes. Overrides content when both are provided.'),
|
|
144
154
|
x: z.number().optional().describe('X position (auto-placed if omitted)'),
|
|
145
155
|
y: z.number().optional().describe('Y position (auto-placed if omitted)'),
|
|
@@ -169,7 +179,10 @@ export async function startMcpServer(): Promise<void> {
|
|
|
169
179
|
...(result.ok ? {} : { isError: true }),
|
|
170
180
|
};
|
|
171
181
|
}
|
|
172
|
-
const
|
|
182
|
+
const nodeInput = input.type === 'image' && input.path && !input.content
|
|
183
|
+
? { ...input, content: input.path }
|
|
184
|
+
: input;
|
|
185
|
+
const id = c.addNode(nodeInput);
|
|
173
186
|
return {
|
|
174
187
|
content: [{ type: 'text', text: JSON.stringify(createdNodePayload(c, id), null, 2) }],
|
|
175
188
|
};
|
|
@@ -442,8 +455,8 @@ export async function startMcpServer(): Promise<void> {
|
|
|
442
455
|
'canvas_add_json_render_node',
|
|
443
456
|
'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
457
|
{
|
|
445
|
-
title: z.string().describe('
|
|
446
|
-
spec:
|
|
458
|
+
title: z.string().optional().describe('Optional node title. If omitted, PMX Canvas infers one from the root element.'),
|
|
459
|
+
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
460
|
x: z.number().optional().describe('Optional X position'),
|
|
448
461
|
y: z.number().optional().describe('Optional Y position'),
|
|
449
462
|
width: z.number().optional().describe('Optional node width'),
|
|
@@ -453,7 +466,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
453
466
|
const c = await ensureCanvas();
|
|
454
467
|
try {
|
|
455
468
|
const result = c.addJsonRenderNode({
|
|
456
|
-
title: input.title,
|
|
469
|
+
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
|
457
470
|
spec: input.spec,
|
|
458
471
|
...(typeof input.x === 'number' ? { x: input.x } : {}),
|
|
459
472
|
...(typeof input.y === 'number' ? { y: input.y } : {}),
|
|
@@ -565,10 +578,16 @@ export async function startMcpServer(): Promise<void> {
|
|
|
565
578
|
y: z.number().optional().describe('New Y position'),
|
|
566
579
|
width: z.number().optional().describe('New width'),
|
|
567
580
|
height: z.number().optional().describe('New height'),
|
|
581
|
+
spec: z.record(z.string(), z.unknown()).optional().describe('New json-render spec, or a graph payload with graphType/data for graph nodes'),
|
|
582
|
+
graphType: z.string().optional().describe('Graph type when updating a graph node'),
|
|
583
|
+
data: z.array(z.record(z.string(), z.unknown())).optional().describe('Graph dataset when updating a graph node'),
|
|
584
|
+
xKey: z.string().optional().describe('Graph x/category key'),
|
|
585
|
+
yKey: z.string().optional().describe('Graph y/value key'),
|
|
586
|
+
chartHeight: z.number().optional().describe('Graph chart content height, distinct from node height'),
|
|
568
587
|
collapsed: z.boolean().optional().describe('Collapse or expand the node'),
|
|
569
588
|
arrangeLocked: z.boolean().optional().describe('Prevent auto-arrange from moving this node. Pinned nodes are also skipped.'),
|
|
570
589
|
},
|
|
571
|
-
async ({ id, title, content, x, y, width, height, collapsed, arrangeLocked }) => {
|
|
590
|
+
async ({ id, title, content, x, y, width, height, spec, graphType, data, xKey, yKey, chartHeight, collapsed, arrangeLocked }) => {
|
|
572
591
|
const c = await ensureCanvas();
|
|
573
592
|
const node = c.getNode(id);
|
|
574
593
|
if (!node) {
|
|
@@ -587,22 +606,21 @@ export async function startMcpServer(): Promise<void> {
|
|
|
587
606
|
if (collapsed !== undefined) {
|
|
588
607
|
patch.collapsed = collapsed;
|
|
589
608
|
}
|
|
590
|
-
if (title !== undefined
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
609
|
+
if (title !== undefined) patch.title = title;
|
|
610
|
+
if (content !== undefined) patch.content = content;
|
|
611
|
+
if (spec !== undefined) patch.spec = spec;
|
|
612
|
+
if (graphType !== undefined) patch.graphType = graphType;
|
|
613
|
+
if (data !== undefined) patch.data = data;
|
|
614
|
+
if (xKey !== undefined) patch.xKey = xKey;
|
|
615
|
+
if (yKey !== undefined) patch.yKey = yKey;
|
|
616
|
+
if (chartHeight !== undefined) patch.chartHeight = chartHeight;
|
|
597
617
|
if (arrangeLocked !== undefined) {
|
|
598
|
-
patch.
|
|
599
|
-
...(patch.data && typeof patch.data === 'object' ? patch.data as Record<string, unknown> : node.data),
|
|
600
|
-
arrangeLocked,
|
|
601
|
-
};
|
|
618
|
+
patch.arrangeLocked = arrangeLocked;
|
|
602
619
|
}
|
|
603
620
|
c.updateNode(id, patch);
|
|
621
|
+
const updated = c.getNode(id);
|
|
604
622
|
return {
|
|
605
|
-
content: [{ type: 'text', text: JSON.stringify({ ok: true, id }) }],
|
|
623
|
+
content: [{ type: 'text', text: JSON.stringify(updated ? createdNodePayload(c, id) : { ok: true, id }, null, 2) }],
|
|
606
624
|
};
|
|
607
625
|
},
|
|
608
626
|
);
|
|
@@ -732,6 +750,31 @@ export async function startMcpServer(): Promise<void> {
|
|
|
732
750
|
},
|
|
733
751
|
);
|
|
734
752
|
|
|
753
|
+
server.tool(
|
|
754
|
+
'canvas_fit_view',
|
|
755
|
+
'Fit the canvas viewport to all nodes or a selected subset. Useful before screenshots and whole-board review.',
|
|
756
|
+
{
|
|
757
|
+
width: z.number().optional().describe('Viewport width used for fit math (default 1440)'),
|
|
758
|
+
height: z.number().optional().describe('Viewport height used for fit math (default 900)'),
|
|
759
|
+
padding: z.number().optional().describe('World-space padding around fitted nodes (default 60)'),
|
|
760
|
+
maxScale: z.number().optional().describe('Maximum zoom scale (default 1)'),
|
|
761
|
+
nodeIds: z.array(z.string()).optional().describe('Optional node IDs to fit instead of the whole canvas'),
|
|
762
|
+
},
|
|
763
|
+
async (input) => {
|
|
764
|
+
const c = await ensureCanvas();
|
|
765
|
+
const result = c.fitView({
|
|
766
|
+
...(typeof input.width === 'number' ? { width: input.width } : {}),
|
|
767
|
+
...(typeof input.height === 'number' ? { height: input.height } : {}),
|
|
768
|
+
...(typeof input.padding === 'number' ? { padding: input.padding } : {}),
|
|
769
|
+
...(typeof input.maxScale === 'number' ? { maxScale: input.maxScale } : {}),
|
|
770
|
+
...(Array.isArray(input.nodeIds) ? { nodeIds: input.nodeIds } : {}),
|
|
771
|
+
});
|
|
772
|
+
return {
|
|
773
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
774
|
+
};
|
|
775
|
+
},
|
|
776
|
+
);
|
|
777
|
+
|
|
735
778
|
// ── canvas_clear ───────────────────────────────────────────────
|
|
736
779
|
server.tool(
|
|
737
780
|
'canvas_clear',
|
|
@@ -22,8 +22,10 @@ import { searchNodes } from './spatial-analysis.js';
|
|
|
22
22
|
import { getCanvasNodeTitle, serializeCanvasNode, type SerializedCanvasNode } from './canvas-serialization.js';
|
|
23
23
|
import {
|
|
24
24
|
buildGraphSpec,
|
|
25
|
+
buildGraphConfig,
|
|
25
26
|
createJsonRenderNodeData,
|
|
26
27
|
GRAPH_NODE_SIZE,
|
|
28
|
+
inferJsonRenderNodeTitle,
|
|
27
29
|
JSON_RENDER_NODE_SIZE,
|
|
28
30
|
normalizeAndValidateJsonRenderSpec,
|
|
29
31
|
type GraphNodeInput,
|
|
@@ -41,6 +43,33 @@ import { buildExcalidrawRestoreCheckpointToolInput, ensureExcalidrawCheckpointId
|
|
|
41
43
|
export type CanvasArrangeMode = 'grid' | 'column' | 'flow';
|
|
42
44
|
export type CanvasPinMode = 'set' | 'add' | 'remove';
|
|
43
45
|
|
|
46
|
+
export interface CanvasFitViewOptions {
|
|
47
|
+
width?: number;
|
|
48
|
+
height?: number;
|
|
49
|
+
padding?: number;
|
|
50
|
+
maxScale?: number;
|
|
51
|
+
nodeIds?: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CanvasFitViewResult {
|
|
55
|
+
ok: true;
|
|
56
|
+
viewport: { x: number; y: number; scale: number };
|
|
57
|
+
nodeCount: number;
|
|
58
|
+
bounds: { x: number; y: number; width: number; height: number } | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CanvasGraphNodeUpdateInput extends Partial<GraphNodeInput> {
|
|
62
|
+
spec?: unknown;
|
|
63
|
+
type?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface CanvasStructuredNodeUpdateInput extends Omit<CanvasGraphNodeUpdateInput, 'data'> {
|
|
67
|
+
content?: unknown;
|
|
68
|
+
data?: unknown;
|
|
69
|
+
arrangeLocked?: unknown;
|
|
70
|
+
chartHeight?: unknown;
|
|
71
|
+
}
|
|
72
|
+
|
|
44
73
|
interface CanvasAddNodeInput {
|
|
45
74
|
type: CanvasNodeState['type'];
|
|
46
75
|
title?: string;
|
|
@@ -83,6 +112,246 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
83
112
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
84
113
|
}
|
|
85
114
|
|
|
115
|
+
function positiveNumber(value: number | undefined, fallback: number): number {
|
|
116
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function pickString(record: Record<string, unknown>, key: string): string | undefined {
|
|
120
|
+
const value = record[key];
|
|
121
|
+
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function pickNumber(record: Record<string, unknown>, key: string): number | undefined {
|
|
125
|
+
const value = record[key];
|
|
126
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function pickStringArray(record: Record<string, unknown>, key: string): string[] | undefined {
|
|
130
|
+
const value = record[key];
|
|
131
|
+
if (!Array.isArray(value)) return undefined;
|
|
132
|
+
const strings = value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
|
|
133
|
+
return strings.length > 0 ? strings : undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function pickGraphData(record: Record<string, unknown>, key: string): Array<Record<string, unknown>> | undefined {
|
|
137
|
+
const value = record[key];
|
|
138
|
+
if (!Array.isArray(value)) return undefined;
|
|
139
|
+
const rows = value.filter((item): item is Record<string, unknown> => isRecord(item));
|
|
140
|
+
return rows.length === value.length ? rows : undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function pickAggregate(record: Record<string, unknown>, key: string): GraphNodeInput['aggregate'] | undefined {
|
|
144
|
+
const value = record[key];
|
|
145
|
+
return value === 'sum' || value === 'count' || value === 'avg' ? value : undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isJsonRenderSpecLike(value: unknown): boolean {
|
|
149
|
+
return isRecord(value) && typeof value.root === 'string' && isRecord(value.elements);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isGraphPayloadLike(value: unknown): value is Record<string, unknown> {
|
|
153
|
+
return isRecord(value) && !isJsonRenderSpecLike(value) && (
|
|
154
|
+
Array.isArray(value.data) || typeof value.graphType === 'string'
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hasGraphUpdateFields(input: Record<string, unknown>): boolean {
|
|
159
|
+
return input.graphType !== undefined ||
|
|
160
|
+
input.type !== undefined ||
|
|
161
|
+
Array.isArray(input.data) ||
|
|
162
|
+
input.xKey !== undefined ||
|
|
163
|
+
input.yKey !== undefined ||
|
|
164
|
+
input.zKey !== undefined ||
|
|
165
|
+
input.nameKey !== undefined ||
|
|
166
|
+
input.valueKey !== undefined ||
|
|
167
|
+
input.axisKey !== undefined ||
|
|
168
|
+
input.metrics !== undefined ||
|
|
169
|
+
input.series !== undefined ||
|
|
170
|
+
input.barKey !== undefined ||
|
|
171
|
+
input.lineKey !== undefined ||
|
|
172
|
+
input.aggregate !== undefined ||
|
|
173
|
+
input.color !== undefined ||
|
|
174
|
+
input.barColor !== undefined ||
|
|
175
|
+
input.lineColor !== undefined ||
|
|
176
|
+
input.chartHeight !== undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function graphUpdateInput(input: CanvasStructuredNodeUpdateInput): CanvasGraphNodeUpdateInput {
|
|
180
|
+
const data = pickGraphData(input as Record<string, unknown>, 'data');
|
|
181
|
+
const {
|
|
182
|
+
data: _data,
|
|
183
|
+
content: _content,
|
|
184
|
+
arrangeLocked: _arrangeLocked,
|
|
185
|
+
chartHeight,
|
|
186
|
+
...graphFields
|
|
187
|
+
} = input;
|
|
188
|
+
return {
|
|
189
|
+
...graphFields,
|
|
190
|
+
...(data ? { data } : {}),
|
|
191
|
+
...(typeof chartHeight === 'number' ? { height: chartHeight } : {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function mergeNodeDataFields(
|
|
196
|
+
base: Record<string, unknown>,
|
|
197
|
+
input: CanvasStructuredNodeUpdateInput,
|
|
198
|
+
): Record<string, unknown> {
|
|
199
|
+
return {
|
|
200
|
+
...base,
|
|
201
|
+
...(isRecord(input.data) ? input.data : {}),
|
|
202
|
+
...(typeof input.arrangeLocked === 'boolean' ? { arrangeLocked: input.arrangeLocked } : {}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function hasStructuredNodeUpdateFields(input: Record<string, unknown>): boolean {
|
|
207
|
+
return input.spec !== undefined || hasGraphUpdateFields(input);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function buildStructuredNodeUpdate(
|
|
211
|
+
node: CanvasNodeState,
|
|
212
|
+
input: CanvasStructuredNodeUpdateInput,
|
|
213
|
+
): { data: Record<string, unknown> } {
|
|
214
|
+
const inputRecord = input as Record<string, unknown>;
|
|
215
|
+
const hasSpec = inputRecord.spec !== undefined;
|
|
216
|
+
const hasGraphFields = hasGraphUpdateFields(inputRecord);
|
|
217
|
+
|
|
218
|
+
if (node.type === 'json-render') {
|
|
219
|
+
if (hasGraphFields) {
|
|
220
|
+
throw new Error(`Graph update fields can only be used with graph nodes, not ${node.type} nodes.`);
|
|
221
|
+
}
|
|
222
|
+
if (!hasSpec) {
|
|
223
|
+
throw new Error('json-render structured updates require a spec.');
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
data: mergeNodeDataFields(buildJsonRenderNodeUpdate(node, {
|
|
227
|
+
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
|
228
|
+
spec: input.spec,
|
|
229
|
+
}).data, input),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (node.type === 'graph') {
|
|
234
|
+
return {
|
|
235
|
+
data: mergeNodeDataFields(buildGraphNodeUpdate(node, graphUpdateInput(input)).data, input),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
throw new Error(`Structured spec and graph updates can only be used with json-render or graph nodes, not ${node.type} nodes.`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function graphConfigToInput(config: Record<string, unknown>, fallbackTitle: string): GraphNodeInput | null {
|
|
243
|
+
const data = pickGraphData(config, 'data');
|
|
244
|
+
if (!data) return null;
|
|
245
|
+
return {
|
|
246
|
+
title: pickString(config, 'title') ?? fallbackTitle,
|
|
247
|
+
graphType: pickString(config, 'graphType') ?? 'line',
|
|
248
|
+
data,
|
|
249
|
+
...(pickString(config, 'xKey') ? { xKey: pickString(config, 'xKey') } : {}),
|
|
250
|
+
...(pickString(config, 'yKey') ? { yKey: pickString(config, 'yKey') } : {}),
|
|
251
|
+
...(pickString(config, 'zKey') ? { zKey: pickString(config, 'zKey') } : {}),
|
|
252
|
+
...(pickString(config, 'nameKey') ? { nameKey: pickString(config, 'nameKey') } : {}),
|
|
253
|
+
...(pickString(config, 'valueKey') ? { valueKey: pickString(config, 'valueKey') } : {}),
|
|
254
|
+
...(pickString(config, 'axisKey') ? { axisKey: pickString(config, 'axisKey') } : {}),
|
|
255
|
+
...(pickStringArray(config, 'metrics') ? { metrics: pickStringArray(config, 'metrics') } : {}),
|
|
256
|
+
...(pickStringArray(config, 'series') ? { series: pickStringArray(config, 'series') } : {}),
|
|
257
|
+
...(pickString(config, 'barKey') ? { barKey: pickString(config, 'barKey') } : {}),
|
|
258
|
+
...(pickString(config, 'lineKey') ? { lineKey: pickString(config, 'lineKey') } : {}),
|
|
259
|
+
...(pickAggregate(config, 'aggregate') ? { aggregate: pickAggregate(config, 'aggregate') } : {}),
|
|
260
|
+
...(pickString(config, 'color') ? { color: pickString(config, 'color') } : {}),
|
|
261
|
+
...(pickString(config, 'barColor') ? { barColor: pickString(config, 'barColor') } : {}),
|
|
262
|
+
...(pickString(config, 'lineColor') ? { lineColor: pickString(config, 'lineColor') } : {}),
|
|
263
|
+
...(pickNumber(config, 'height') !== undefined ? { height: pickNumber(config, 'height') } : {}),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function mergeGraphInput(source: Record<string, unknown>, fallback: GraphNodeInput | null): GraphNodeInput {
|
|
268
|
+
const data = pickGraphData(source, 'data') ?? fallback?.data;
|
|
269
|
+
if (!data) throw new Error('Graph update requires a data array, either in the update payload or the existing graphConfig.');
|
|
270
|
+
return {
|
|
271
|
+
title: pickString(source, 'title') ?? fallback?.title ?? 'Graph',
|
|
272
|
+
graphType: pickString(source, 'graphType') ?? pickString(source, 'type') ?? fallback?.graphType ?? 'line',
|
|
273
|
+
data,
|
|
274
|
+
...((pickString(source, 'xKey') ?? fallback?.xKey) ? { xKey: pickString(source, 'xKey') ?? fallback?.xKey } : {}),
|
|
275
|
+
...((pickString(source, 'yKey') ?? fallback?.yKey) ? { yKey: pickString(source, 'yKey') ?? fallback?.yKey } : {}),
|
|
276
|
+
...((pickString(source, 'zKey') ?? fallback?.zKey) ? { zKey: pickString(source, 'zKey') ?? fallback?.zKey } : {}),
|
|
277
|
+
...((pickString(source, 'nameKey') ?? fallback?.nameKey) ? { nameKey: pickString(source, 'nameKey') ?? fallback?.nameKey } : {}),
|
|
278
|
+
...((pickString(source, 'valueKey') ?? fallback?.valueKey) ? { valueKey: pickString(source, 'valueKey') ?? fallback?.valueKey } : {}),
|
|
279
|
+
...((pickString(source, 'axisKey') ?? fallback?.axisKey) ? { axisKey: pickString(source, 'axisKey') ?? fallback?.axisKey } : {}),
|
|
280
|
+
...((pickStringArray(source, 'metrics') ?? fallback?.metrics) ? { metrics: pickStringArray(source, 'metrics') ?? fallback?.metrics } : {}),
|
|
281
|
+
...((pickStringArray(source, 'series') ?? fallback?.series) ? { series: pickStringArray(source, 'series') ?? fallback?.series } : {}),
|
|
282
|
+
...((pickString(source, 'barKey') ?? fallback?.barKey) ? { barKey: pickString(source, 'barKey') ?? fallback?.barKey } : {}),
|
|
283
|
+
...((pickString(source, 'lineKey') ?? fallback?.lineKey) ? { lineKey: pickString(source, 'lineKey') ?? fallback?.lineKey } : {}),
|
|
284
|
+
...((pickAggregate(source, 'aggregate') ?? fallback?.aggregate) ? { aggregate: pickAggregate(source, 'aggregate') ?? fallback?.aggregate } : {}),
|
|
285
|
+
...((pickString(source, 'color') ?? fallback?.color) ? { color: pickString(source, 'color') ?? fallback?.color } : {}),
|
|
286
|
+
...((pickString(source, 'barColor') ?? fallback?.barColor) ? { barColor: pickString(source, 'barColor') ?? fallback?.barColor } : {}),
|
|
287
|
+
...((pickString(source, 'lineColor') ?? fallback?.lineColor) ? { lineColor: pickString(source, 'lineColor') ?? fallback?.lineColor } : {}),
|
|
288
|
+
...((pickNumber(source, 'height') ?? fallback?.height) !== undefined ? { height: pickNumber(source, 'height') ?? fallback?.height } : {}),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function buildJsonRenderNodeUpdate(
|
|
293
|
+
node: CanvasNodeState,
|
|
294
|
+
input: { title?: string; spec: unknown },
|
|
295
|
+
): { data: Record<string, unknown>; spec: JsonRenderSpec } {
|
|
296
|
+
if (node.type !== 'json-render') throw new Error(`Node "${node.id}" is not a json-render node.`);
|
|
297
|
+
const spec = normalizeAndValidateJsonRenderSpec(input.spec);
|
|
298
|
+
const title = input.title?.trim() || inferJsonRenderNodeTitle(spec);
|
|
299
|
+
return {
|
|
300
|
+
spec,
|
|
301
|
+
data: {
|
|
302
|
+
...node.data,
|
|
303
|
+
...createJsonRenderNodeData(node.id, title, spec, { viewerType: 'json-render' }),
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function buildGraphNodeUpdate(
|
|
309
|
+
node: CanvasNodeState,
|
|
310
|
+
input: CanvasGraphNodeUpdateInput,
|
|
311
|
+
): { data: Record<string, unknown>; spec: JsonRenderSpec; graphConfig: Record<string, unknown> } {
|
|
312
|
+
if (node.type !== 'graph') throw new Error(`Node "${node.id}" is not a graph node.`);
|
|
313
|
+
const currentConfig = isRecord(node.data.graphConfig) ? node.data.graphConfig : {};
|
|
314
|
+
const fallbackTitle = typeof node.data.title === 'string' ? node.data.title : 'Graph';
|
|
315
|
+
const fallback = graphConfigToInput(currentConfig, fallbackTitle);
|
|
316
|
+
const source = isGraphPayloadLike(input.spec)
|
|
317
|
+
? input.spec
|
|
318
|
+
: Object.fromEntries(
|
|
319
|
+
Object.entries(input).filter(([key, value]) => key !== 'spec' && value !== undefined),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
if (input.spec !== undefined && !isGraphPayloadLike(input.spec)) {
|
|
323
|
+
const spec = normalizeAndValidateJsonRenderSpec(input.spec);
|
|
324
|
+
const title = input.title?.trim() || fallbackTitle;
|
|
325
|
+
return {
|
|
326
|
+
spec,
|
|
327
|
+
graphConfig: currentConfig,
|
|
328
|
+
data: {
|
|
329
|
+
...node.data,
|
|
330
|
+
...createJsonRenderNodeData(node.id, title, spec, {
|
|
331
|
+
viewerType: 'graph',
|
|
332
|
+
graphConfig: currentConfig,
|
|
333
|
+
}),
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const graphInput = mergeGraphInput(source, fallback);
|
|
339
|
+
const spec = buildGraphSpec(graphInput);
|
|
340
|
+
const graphConfig = buildGraphConfig(graphInput);
|
|
341
|
+
const title = graphInput.title?.trim() || 'Graph';
|
|
342
|
+
return {
|
|
343
|
+
spec,
|
|
344
|
+
graphConfig,
|
|
345
|
+
data: {
|
|
346
|
+
...node.data,
|
|
347
|
+
...createJsonRenderNodeData(node.id, title, spec, {
|
|
348
|
+
viewerType: 'graph',
|
|
349
|
+
graphConfig,
|
|
350
|
+
}),
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
86
355
|
function getStoredExcalidrawCheckpointId(node: CanvasNodeState): string | null {
|
|
87
356
|
const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
|
|
88
357
|
const checkpointId = appCheckpoint?.id;
|
|
@@ -1000,7 +1269,7 @@ export function createCanvasJsonRenderNode(
|
|
|
1000
1269
|
collapsed: false,
|
|
1001
1270
|
pinned: false,
|
|
1002
1271
|
dockPosition: null,
|
|
1003
|
-
data: createJsonRenderNodeData(id, input.title, spec, {
|
|
1272
|
+
data: createJsonRenderNodeData(id, input.title?.trim() || inferJsonRenderNodeTitle(spec), spec, {
|
|
1004
1273
|
viewerType: 'json-render',
|
|
1005
1274
|
}),
|
|
1006
1275
|
};
|
|
@@ -1032,26 +1301,7 @@ export function createCanvasGraphNode(
|
|
|
1032
1301
|
dockPosition: null,
|
|
1033
1302
|
data: createJsonRenderNodeData(id, title, spec, {
|
|
1034
1303
|
viewerType: 'graph',
|
|
1035
|
-
graphConfig:
|
|
1036
|
-
title,
|
|
1037
|
-
graphType: input.graphType,
|
|
1038
|
-
data: input.data,
|
|
1039
|
-
...(input.xKey ? { xKey: input.xKey } : {}),
|
|
1040
|
-
...(input.yKey ? { yKey: input.yKey } : {}),
|
|
1041
|
-
...(input.zKey ? { zKey: input.zKey } : {}),
|
|
1042
|
-
...(input.nameKey ? { nameKey: input.nameKey } : {}),
|
|
1043
|
-
...(input.valueKey ? { valueKey: input.valueKey } : {}),
|
|
1044
|
-
...(input.axisKey ? { axisKey: input.axisKey } : {}),
|
|
1045
|
-
...(input.metrics?.length ? { metrics: input.metrics } : {}),
|
|
1046
|
-
...(input.series?.length ? { series: input.series } : {}),
|
|
1047
|
-
...(input.barKey ? { barKey: input.barKey } : {}),
|
|
1048
|
-
...(input.lineKey ? { lineKey: input.lineKey } : {}),
|
|
1049
|
-
...(input.aggregate ? { aggregate: input.aggregate } : {}),
|
|
1050
|
-
...(input.color ? { color: input.color } : {}),
|
|
1051
|
-
...(input.barColor ? { barColor: input.barColor } : {}),
|
|
1052
|
-
...(input.lineColor ? { lineColor: input.lineColor } : {}),
|
|
1053
|
-
...(typeof input.height === 'number' ? { height: input.height } : {}),
|
|
1054
|
-
},
|
|
1304
|
+
graphConfig: buildGraphConfig(input),
|
|
1055
1305
|
}),
|
|
1056
1306
|
};
|
|
1057
1307
|
|
|
@@ -1059,6 +1309,46 @@ export function createCanvasGraphNode(
|
|
|
1059
1309
|
return { id, url: String(node.data.url), spec, node };
|
|
1060
1310
|
}
|
|
1061
1311
|
|
|
1312
|
+
export function fitCanvasView(options: CanvasFitViewOptions = {}): CanvasFitViewResult {
|
|
1313
|
+
const width = positiveNumber(options.width, 1440);
|
|
1314
|
+
const height = positiveNumber(options.height, 900);
|
|
1315
|
+
const padding = positiveNumber(options.padding, 60);
|
|
1316
|
+
const maxScale = positiveNumber(options.maxScale, 1);
|
|
1317
|
+
const nodeIdFilter = options.nodeIds && options.nodeIds.length > 0 ? new Set(options.nodeIds) : null;
|
|
1318
|
+
const targetNodes = canvasState.getLayout().nodes.filter((node) => !nodeIdFilter || nodeIdFilter.has(node.id));
|
|
1319
|
+
|
|
1320
|
+
if (targetNodes.length === 0) {
|
|
1321
|
+
const viewport = canvasState.viewport;
|
|
1322
|
+
return { ok: true, viewport, nodeCount: 0, bounds: null };
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
1326
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
1327
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
1328
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
1329
|
+
for (const node of targetNodes) {
|
|
1330
|
+
minX = Math.min(minX, node.position.x);
|
|
1331
|
+
minY = Math.min(minY, node.position.y);
|
|
1332
|
+
maxX = Math.max(maxX, node.position.x + node.size.width);
|
|
1333
|
+
maxY = Math.max(maxY, node.position.y + node.size.height);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const bounds = { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
1337
|
+
const worldWidth = Math.max(1, bounds.width + padding * 2);
|
|
1338
|
+
const worldHeight = Math.max(1, bounds.height + padding * 2);
|
|
1339
|
+
const scale = Math.min(maxScale, width / worldWidth, height / worldHeight);
|
|
1340
|
+
const centerX = minX + bounds.width / 2;
|
|
1341
|
+
const centerY = minY + bounds.height / 2;
|
|
1342
|
+
const viewport = {
|
|
1343
|
+
x: width / 2 - centerX * scale,
|
|
1344
|
+
y: height / 2 - centerY * scale,
|
|
1345
|
+
scale,
|
|
1346
|
+
};
|
|
1347
|
+
|
|
1348
|
+
canvasState.setViewport(viewport);
|
|
1349
|
+
return { ok: true, viewport: canvasState.viewport, nodeCount: targetNodes.length, bounds };
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1062
1352
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
1063
1353
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
1064
1354
|
}
|
|
@@ -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.' },
|
|
@@ -439,6 +439,8 @@ export function describeCanvasSchema(): {
|
|
|
439
439
|
'canvas_build_web_artifact',
|
|
440
440
|
'canvas_open_mcp_app',
|
|
441
441
|
'canvas_create_group',
|
|
442
|
+
'canvas_update_node',
|
|
443
|
+
'canvas_fit_view',
|
|
442
444
|
'canvas_describe_schema',
|
|
443
445
|
'canvas_validate_spec',
|
|
444
446
|
],
|