pmx-canvas 0.1.18 → 0.1.20
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 +128 -0
- package/Readme.md +19 -6
- package/dist/canvas/global.css +35 -2
- package/dist/canvas/index.js +70 -69
- package/dist/json-render/index.js +109 -109
- package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
- package/dist/types/client/icons.d.ts +2 -0
- package/dist/types/client/state/canvas-store.d.ts +2 -0
- package/dist/types/client/types.d.ts +2 -1
- package/dist/types/json-render/charts/components.d.ts +5 -1
- package/dist/types/json-render/renderer/index.d.ts +1 -0
- package/dist/types/json-render/server.d.ts +1 -0
- package/dist/types/mcp/canvas-access.d.ts +3 -0
- package/dist/types/server/canvas-operations.d.ts +4 -0
- package/dist/types/server/canvas-schema.d.ts +19 -3
- package/dist/types/server/canvas-serialization.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +8 -2
- package/dist/types/server/html-primitives.d.ts +34 -0
- package/dist/types/server/index.d.ts +19 -0
- package/docs/RELEASE.md +153 -0
- package/docs/bun-webview-integration.md +296 -0
- package/docs/cli.md +143 -0
- package/docs/evals/e2e-cli-coverage.md +61 -0
- package/docs/http-api.md +201 -0
- package/docs/mcp.md +137 -0
- package/docs/node-types.md +272 -0
- package/docs/plans/.gitkeep +0 -0
- package/docs/plans/plan-001-semantic-watch-mvp.md +335 -0
- package/docs/plans/plan-002-human-attention-layer-design-spec.md +679 -0
- package/docs/plans/plan-003-human-attention-layer-implementation-plan.md +572 -0
- package/docs/reactive-canvas-proposal.md +578 -0
- package/docs/release-review-0.1.0.md +38 -0
- package/docs/screenshot.png +0 -0
- package/docs/screenshots/demo-workbench-dark.png +0 -0
- package/docs/screenshots/demo-workbench-light.png +0 -0
- package/docs/screenshots/welcome-dark.png +0 -0
- package/docs/screenshots/welcome-light.png +0 -0
- package/docs/sdk.md +103 -0
- package/package.json +2 -1
- package/skills/pmx-canvas/SKILL.md +8 -0
- package/src/cli/agent.ts +167 -5
- package/src/client/App.tsx +20 -1
- package/src/client/canvas/AnnotationLayer.tsx +33 -12
- package/src/client/canvas/CanvasViewport.tsx +88 -7
- package/src/client/canvas/CommandPalette.tsx +1 -1
- package/src/client/canvas/ContextMenu.tsx +2 -2
- package/src/client/canvas/ExpandedNodeOverlay.tsx +7 -1
- package/src/client/icons.tsx +13 -0
- package/src/client/nodes/McpAppNode.tsx +12 -4
- package/src/client/state/canvas-store.ts +15 -5
- package/src/client/state/sse-bridge.ts +4 -3
- package/src/client/theme/global.css +35 -2
- package/src/client/types.ts +2 -1
- package/src/json-render/charts/components.tsx +41 -7
- package/src/json-render/charts/extra-components.tsx +13 -12
- package/src/json-render/renderer/index.tsx +1 -0
- package/src/json-render/server.ts +3 -1
- package/src/mcp/canvas-access.ts +25 -0
- package/src/mcp/server.ts +85 -27
- package/src/server/agent-context.ts +17 -0
- package/src/server/canvas-operations.ts +91 -38
- package/src/server/canvas-schema.ts +83 -3
- package/src/server/canvas-serialization.ts +9 -2
- package/src/server/canvas-state.ts +27 -9
- package/src/server/demo-state.json +1143 -0
- package/src/server/demo.ts +25 -777
- package/src/server/html-primitives.ts +990 -0
- package/src/server/index.ts +43 -2
- package/src/server/server.ts +140 -14
- package/src/server/spatial-analysis.ts +3 -3
package/src/mcp/canvas-access.ts
CHANGED
|
@@ -19,6 +19,8 @@ type AddDiagramInput = Parameters<PmxCanvas['addDiagram']>[0];
|
|
|
19
19
|
type AddJsonRenderNodeInput = Parameters<PmxCanvas['addJsonRenderNode']>[0];
|
|
20
20
|
type AddJsonRenderNodeResult = ReturnType<PmxCanvas['addJsonRenderNode']>;
|
|
21
21
|
type AddHtmlNodeInput = Parameters<PmxCanvas['addHtmlNode']>[0];
|
|
22
|
+
type AddHtmlPrimitiveInput = Parameters<PmxCanvas['addHtmlPrimitive']>[0];
|
|
23
|
+
type AddHtmlPrimitiveResult = ReturnType<PmxCanvas['addHtmlPrimitive']>;
|
|
22
24
|
type AddGraphNodeInput = Parameters<PmxCanvas['addGraphNode']>[0];
|
|
23
25
|
type AddGraphNodeResult = ReturnType<PmxCanvas['addGraphNode']>;
|
|
24
26
|
type UpdateNodePatch = Parameters<PmxCanvas['updateNode']>[1];
|
|
@@ -102,6 +104,7 @@ export interface CanvasAccess {
|
|
|
102
104
|
addDiagram(input: AddDiagramInput): Promise<OpenMcpAppResult>;
|
|
103
105
|
addJsonRenderNode(input: AddJsonRenderNodeInput): Promise<AddJsonRenderNodeResult>;
|
|
104
106
|
addHtmlNode(input: AddHtmlNodeInput): Promise<string>;
|
|
107
|
+
addHtmlPrimitive(input: AddHtmlPrimitiveInput): Promise<AddHtmlPrimitiveResult>;
|
|
105
108
|
addGraphNode(input: AddGraphNodeInput): Promise<AddGraphNodeResult>;
|
|
106
109
|
buildWebArtifact(input: WebArtifactInput): Promise<WebArtifactResult>;
|
|
107
110
|
updateNode(id: string, patch: UpdateNodePatch): Promise<void>;
|
|
@@ -188,6 +191,10 @@ class LocalCanvasAccess implements CanvasAccess {
|
|
|
188
191
|
return this.canvas.addHtmlNode(input);
|
|
189
192
|
}
|
|
190
193
|
|
|
194
|
+
async addHtmlPrimitive(input: AddHtmlPrimitiveInput): Promise<AddHtmlPrimitiveResult> {
|
|
195
|
+
return this.canvas.addHtmlPrimitive(input);
|
|
196
|
+
}
|
|
197
|
+
|
|
191
198
|
async addGraphNode(input: AddGraphNodeInput): Promise<AddGraphNodeResult> {
|
|
192
199
|
return this.canvas.addGraphNode(input);
|
|
193
200
|
}
|
|
@@ -438,6 +445,22 @@ class RemoteCanvasAccess implements CanvasAccess {
|
|
|
438
445
|
return await this.requestNodeId('POST', '/api/canvas/node', { type: 'html', ...input });
|
|
439
446
|
}
|
|
440
447
|
|
|
448
|
+
async addHtmlPrimitive(input: AddHtmlPrimitiveInput): Promise<AddHtmlPrimitiveResult> {
|
|
449
|
+
const response = await this.requestJson<{
|
|
450
|
+
id?: string;
|
|
451
|
+
node?: { id?: string };
|
|
452
|
+
primitive?: { kind?: string; title?: string; htmlBytes?: number };
|
|
453
|
+
}>('POST', '/api/canvas/node', { type: 'html', ...input, primitive: input.kind });
|
|
454
|
+
const id = typeof response.id === 'string' ? response.id : response.node?.id;
|
|
455
|
+
if (!id) throw new Error('html primitive response did not include a node id.');
|
|
456
|
+
return {
|
|
457
|
+
id,
|
|
458
|
+
kind: input.kind,
|
|
459
|
+
title: response.primitive?.title ?? input.title ?? input.kind,
|
|
460
|
+
htmlBytes: response.primitive?.htmlBytes ?? 0,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
441
464
|
async addGraphNode(input: AddGraphNodeInput): Promise<AddGraphNodeResult> {
|
|
442
465
|
const response = await this.requestJson<GraphNodeResponse>('POST', '/api/canvas/graph', {
|
|
443
466
|
...input,
|
|
@@ -559,6 +582,8 @@ class RemoteCanvasAccess implements CanvasAccess {
|
|
|
559
582
|
const params = new URLSearchParams();
|
|
560
583
|
if (typeof options?.limit === 'number') params.set('limit', String(options.limit));
|
|
561
584
|
if (options?.query) params.set('q', options.query);
|
|
585
|
+
if (options?.before) params.set('before', options.before);
|
|
586
|
+
if (options?.after) params.set('after', options.after);
|
|
562
587
|
if (options?.all) params.set('all', 'true');
|
|
563
588
|
const query = params.size > 0 ? `?${params.toString()}` : '';
|
|
564
589
|
return await this.requestJson<SnapshotList>('GET', `/api/canvas/snapshots${query}`);
|
package/src/mcp/server.ts
CHANGED
|
@@ -25,6 +25,8 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
25
25
|
import { isAbsolute, relative, resolve } from 'node:path';
|
|
26
26
|
import { z } from 'zod';
|
|
27
27
|
import { canvasState, describeCanvasSchema, validateStructuredCanvasPayload } from '../server/index.js';
|
|
28
|
+
import { isHtmlPrimitiveKind } from '../server/html-primitives.js';
|
|
29
|
+
import type { HtmlPrimitiveKind } from '../server/html-primitives.js';
|
|
28
30
|
import { createCanvasAccess, refreshCanvasAccess, type CanvasAccess } from './canvas-access.js';
|
|
29
31
|
import { serializeNodeForAgentContext } from '../server/agent-context.js';
|
|
30
32
|
import { wrapCanvasAutomationScript } from '../server/server.js';
|
|
@@ -56,6 +58,8 @@ const jsonRenderSpecSchema = z.union([
|
|
|
56
58
|
}).passthrough(),
|
|
57
59
|
]);
|
|
58
60
|
|
|
61
|
+
const htmlPrimitiveKindSchema = z.string().refine(isHtmlPrimitiveKind, 'Unknown HTML primitive kind');
|
|
62
|
+
|
|
59
63
|
function structuredSchemaDescription(): string {
|
|
60
64
|
const routing = describeCanvasSchema().mcp.nodeTypeRouting;
|
|
61
65
|
return Object.entries(routing)
|
|
@@ -428,6 +432,46 @@ export async function startMcpServer(): Promise<void> {
|
|
|
428
432
|
},
|
|
429
433
|
);
|
|
430
434
|
|
|
435
|
+
server.tool(
|
|
436
|
+
'canvas_add_html_primitive',
|
|
437
|
+
'Create a reusable HTML communication primitive as a normal sandboxed html node. Use this instead of long markdown for side-by-side choices, implementation plans, PR review sheets, module maps, design sheets, component galleries, flowcharts, slide decks, explainers, status reports, and throwaway editors with export/copy paths.',
|
|
438
|
+
{
|
|
439
|
+
kind: htmlPrimitiveKindSchema.describe('Primitive kind. Call canvas_describe_schema and read htmlPrimitives for data shapes and examples.'),
|
|
440
|
+
title: z.string().optional().describe('Node title shown in the canvas titlebar.'),
|
|
441
|
+
data: z.record(z.string(), z.unknown()).optional().describe('Primitive-specific data payload. See canvas_describe_schema.htmlPrimitives for each shape.'),
|
|
442
|
+
x: z.number().optional().describe('X position (auto-placed if omitted).'),
|
|
443
|
+
y: z.number().optional().describe('Y position (auto-placed if omitted).'),
|
|
444
|
+
width: z.number().optional().describe('Width in pixels (defaults per primitive).'),
|
|
445
|
+
height: z.number().optional().describe('Height in pixels (defaults per primitive).'),
|
|
446
|
+
strictSize: z.boolean().optional().describe('Keep explicit width/height fixed; iframe scrolls overflow internally.'),
|
|
447
|
+
full: z.boolean().optional().describe('Return the full created node payload. Default false returns compact metadata.'),
|
|
448
|
+
verbose: z.boolean().optional().describe('Alias for full:true.'),
|
|
449
|
+
},
|
|
450
|
+
async (input) => {
|
|
451
|
+
const c = await ensureCanvas();
|
|
452
|
+
const kind = input.kind as HtmlPrimitiveKind;
|
|
453
|
+
const result = await c.addHtmlPrimitive({
|
|
454
|
+
kind,
|
|
455
|
+
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
|
456
|
+
...(input.data ? { data: input.data } : {}),
|
|
457
|
+
...(typeof input.x === 'number' ? { x: input.x } : {}),
|
|
458
|
+
...(typeof input.y === 'number' ? { y: input.y } : {}),
|
|
459
|
+
...(typeof input.width === 'number' ? { width: input.width } : {}),
|
|
460
|
+
...(typeof input.height === 'number' ? { height: input.height } : {}),
|
|
461
|
+
...(input.strictSize === true ? { strictSize: true } : {}),
|
|
462
|
+
});
|
|
463
|
+
return {
|
|
464
|
+
content: [{
|
|
465
|
+
type: 'text',
|
|
466
|
+
text: JSON.stringify({
|
|
467
|
+
...(await createdNodePayload(c, result.id, input)),
|
|
468
|
+
primitive: { kind: result.kind, title: result.title, htmlBytes: result.htmlBytes },
|
|
469
|
+
}, null, 2),
|
|
470
|
+
}],
|
|
471
|
+
};
|
|
472
|
+
},
|
|
473
|
+
);
|
|
474
|
+
|
|
431
475
|
server.tool(
|
|
432
476
|
'canvas_open_mcp_app',
|
|
433
477
|
'Connect to an external MCP server that declares a ui:// app resource, call the specified tool, and open the resulting MCP App inside a canvas mcp-app node. This is a full external-MCP transport call, not the CLI kind shortcut; use canvas_add_diagram for the built-in Excalidraw preset.',
|
|
@@ -543,10 +587,13 @@ export async function startMcpServer(): Promise<void> {
|
|
|
543
587
|
|
|
544
588
|
server.tool(
|
|
545
589
|
'canvas_validate_spec',
|
|
546
|
-
'Validate a json-render spec or
|
|
590
|
+
'Validate a json-render spec, graph payload, or HTML primitive payload without creating a node. Returns normalized metadata the server would accept.',
|
|
547
591
|
{
|
|
548
|
-
type: z.enum(['json-render', 'graph']).describe('Structured payload type to validate'),
|
|
592
|
+
type: z.enum(['json-render', 'graph', 'html-primitive']).describe('Structured payload type to validate'),
|
|
549
593
|
spec: jsonRenderSpecSchema.optional().describe('json-render spec to validate when type="json-render"'),
|
|
594
|
+
kind: htmlPrimitiveKindSchema.optional().describe('HTML primitive kind when type="html-primitive"'),
|
|
595
|
+
primitive: htmlPrimitiveKindSchema.optional().describe('Alias for kind when type="html-primitive"'),
|
|
596
|
+
primitiveData: z.record(z.string(), z.unknown()).optional().describe('HTML primitive data payload when type="html-primitive"'),
|
|
550
597
|
title: z.string().optional().describe('Optional graph title'),
|
|
551
598
|
graphType: z.string().optional().describe('Graph type when type="graph"'),
|
|
552
599
|
data: z.array(z.record(z.string(), z.unknown())).optional().describe('Graph dataset when type="graph"'),
|
|
@@ -573,29 +620,38 @@ export async function startMcpServer(): Promise<void> {
|
|
|
573
620
|
type: 'json-render',
|
|
574
621
|
spec: input.spec,
|
|
575
622
|
})
|
|
576
|
-
:
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
623
|
+
: input.type === 'html-primitive'
|
|
624
|
+
? validateStructuredCanvasPayload({
|
|
625
|
+
type: 'html-primitive',
|
|
626
|
+
primitive: {
|
|
627
|
+
kind: (input.kind ?? input.primitive ?? '') as HtmlPrimitiveKind | '',
|
|
628
|
+
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
|
629
|
+
...(input.primitiveData ? { data: input.primitiveData } : {}),
|
|
630
|
+
},
|
|
631
|
+
})
|
|
632
|
+
: validateStructuredCanvasPayload({
|
|
633
|
+
type: 'graph',
|
|
634
|
+
graph: {
|
|
635
|
+
title: input.title,
|
|
636
|
+
graphType: input.graphType ?? 'line',
|
|
637
|
+
data: input.data ?? [],
|
|
638
|
+
...(typeof input.xKey === 'string' ? { xKey: input.xKey } : {}),
|
|
639
|
+
...(typeof input.yKey === 'string' ? { yKey: input.yKey } : {}),
|
|
640
|
+
...(typeof input.zKey === 'string' ? { zKey: input.zKey } : {}),
|
|
641
|
+
...(typeof input.nameKey === 'string' ? { nameKey: input.nameKey } : {}),
|
|
642
|
+
...(typeof input.valueKey === 'string' ? { valueKey: input.valueKey } : {}),
|
|
643
|
+
...(typeof input.axisKey === 'string' ? { axisKey: input.axisKey } : {}),
|
|
644
|
+
...(Array.isArray(input.metrics) ? { metrics: input.metrics } : {}),
|
|
645
|
+
...(Array.isArray(input.series) ? { series: input.series } : {}),
|
|
646
|
+
...(typeof input.barKey === 'string' ? { barKey: input.barKey } : {}),
|
|
647
|
+
...(typeof input.lineKey === 'string' ? { lineKey: input.lineKey } : {}),
|
|
648
|
+
...(typeof input.aggregate === 'string' ? { aggregate: input.aggregate } : {}),
|
|
649
|
+
...(typeof input.color === 'string' ? { color: input.color } : {}),
|
|
650
|
+
...(typeof input.barColor === 'string' ? { barColor: input.barColor } : {}),
|
|
651
|
+
...(typeof input.lineColor === 'string' ? { lineColor: input.lineColor } : {}),
|
|
652
|
+
...(typeof input.height === 'number' ? { height: input.height } : {}),
|
|
653
|
+
},
|
|
654
|
+
});
|
|
599
655
|
|
|
600
656
|
return {
|
|
601
657
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
@@ -736,7 +792,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
736
792
|
content: [{
|
|
737
793
|
type: 'text',
|
|
738
794
|
text: JSON.stringify({
|
|
739
|
-
...await createdNodePayload(c, result.id),
|
|
795
|
+
...(await createdNodePayload(c, result.id)),
|
|
740
796
|
url: result.url,
|
|
741
797
|
spec: result.spec,
|
|
742
798
|
}, null, 2),
|
|
@@ -816,7 +872,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
816
872
|
content: [{
|
|
817
873
|
type: 'text',
|
|
818
874
|
text: JSON.stringify({
|
|
819
|
-
...await createdNodePayload(c, result.id),
|
|
875
|
+
...(await createdNodePayload(c, result.id)),
|
|
820
876
|
url: result.url,
|
|
821
877
|
spec: result.spec,
|
|
822
878
|
}, null, 2),
|
|
@@ -1759,6 +1815,8 @@ export async function startMcpServer(): Promise<void> {
|
|
|
1759
1815
|
{
|
|
1760
1816
|
limit: z.number().optional().describe('Maximum snapshots to return (default: 20)'),
|
|
1761
1817
|
query: z.string().optional().describe('Optional case-insensitive ID/name filter'),
|
|
1818
|
+
before: z.string().optional().describe('Only return snapshots created at or before this ISO timestamp'),
|
|
1819
|
+
after: z.string().optional().describe('Only return snapshots created at or after this ISO timestamp'),
|
|
1762
1820
|
all: z.boolean().optional().describe('Return all snapshots instead of the default limit'),
|
|
1763
1821
|
},
|
|
1764
1822
|
async (input) => {
|
|
@@ -155,6 +155,17 @@ function summarizeWebArtifactData(data: Record<string, unknown>, maxLength: numb
|
|
|
155
155
|
return parts.length > 0 ? truncateContextText(parts.join('\n'), maxLength) : 'Web artifact node';
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
function summarizeHtmlPrimitiveData(data: Record<string, unknown>, maxLength: number): string {
|
|
159
|
+
const parts: string[] = [];
|
|
160
|
+
const primitive = typeof data.htmlPrimitive === 'string' ? data.htmlPrimitive : '';
|
|
161
|
+
const description = typeof data.description === 'string' ? data.description : '';
|
|
162
|
+
const primitiveData = data.primitiveData;
|
|
163
|
+
if (primitive) parts.push(`HTML primitive: ${primitive}`);
|
|
164
|
+
if (description) parts.push(description);
|
|
165
|
+
if (primitiveData !== undefined) parts.push(`Data: ${stringifyContextValue(primitiveData, maxLength)}`);
|
|
166
|
+
return truncateContextText(parts.join('\n'), maxLength);
|
|
167
|
+
}
|
|
168
|
+
|
|
158
169
|
function metadataForNode(node: CanvasNodeState): Record<string, unknown> | undefined {
|
|
159
170
|
switch (node.type) {
|
|
160
171
|
case 'webpage': {
|
|
@@ -233,6 +244,12 @@ export function summarizeNodeForAgentContext(
|
|
|
233
244
|
if (graphCfg) return truncateContextText(`Graph: ${JSON.stringify(graphCfg)}`, defaultTextLength);
|
|
234
245
|
return stringifyContextValue(node.data.spec ?? {}, defaultTextLength);
|
|
235
246
|
}
|
|
247
|
+
case 'html': {
|
|
248
|
+
if (typeof node.data.htmlPrimitive === 'string') {
|
|
249
|
+
return summarizeHtmlPrimitiveData(node.data, defaultTextLength);
|
|
250
|
+
}
|
|
251
|
+
return stringifyContextValue({ title: node.data.title, description: node.data.description }, defaultTextLength);
|
|
252
|
+
}
|
|
236
253
|
case 'prompt':
|
|
237
254
|
case 'response': {
|
|
238
255
|
const text = (node.data.text as string) || (node.data.content as string) || '';
|
|
@@ -20,6 +20,7 @@ import { mutationHistory } from './mutation-history.js';
|
|
|
20
20
|
import { computeGroupBounds, findOpenCanvasPosition } from './placement.js';
|
|
21
21
|
import { searchNodes } from './spatial-analysis.js';
|
|
22
22
|
import { getCanvasNodeTitle, serializeCanvasNode, type SerializedCanvasNode } from './canvas-serialization.js';
|
|
23
|
+
import { computeAutoArrange } from '../shared/auto-arrange.js';
|
|
23
24
|
import {
|
|
24
25
|
buildGraphSpec,
|
|
25
26
|
buildGraphConfig,
|
|
@@ -102,6 +103,8 @@ interface CanvasAddNodeInput {
|
|
|
102
103
|
strictSize?: boolean;
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
export const MARKDOWN_NODE_DEFAULT_SIZE = { width: 520, height: 360 };
|
|
107
|
+
|
|
105
108
|
interface CanvasCreateGroupInput {
|
|
106
109
|
title?: string;
|
|
107
110
|
childIds?: string[];
|
|
@@ -1070,62 +1073,105 @@ function collectArrangeExcludedNodeIds(nodes: CanvasNodeState[]): Set<string> {
|
|
|
1070
1073
|
return excluded;
|
|
1071
1074
|
}
|
|
1072
1075
|
|
|
1076
|
+
function collectGridArrangeExcludedNodeIds(nodes: CanvasNodeState[]): Set<string> {
|
|
1077
|
+
const nodesById = new Map(nodes.map((node) => [node.id, node]));
|
|
1078
|
+
const excluded = new Set<string>();
|
|
1079
|
+
|
|
1080
|
+
for (const node of nodes) {
|
|
1081
|
+
if (isArrangeLocked(node)) excluded.add(node.id);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
for (const node of nodes) {
|
|
1085
|
+
if (node.type !== 'group') continue;
|
|
1086
|
+
const childIds = Array.isArray(node.data.children)
|
|
1087
|
+
? node.data.children.filter((id): id is string => typeof id === 'string')
|
|
1088
|
+
: [];
|
|
1089
|
+
const hasLockedChild = childIds.some((childId) => {
|
|
1090
|
+
const child = nodesById.get(childId);
|
|
1091
|
+
return child ? isArrangeLocked(child) : false;
|
|
1092
|
+
});
|
|
1093
|
+
if (!excluded.has(node.id) && !hasLockedChild) continue;
|
|
1094
|
+
|
|
1095
|
+
excluded.add(node.id);
|
|
1096
|
+
for (const childId of childIds) excluded.add(childId);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
return excluded;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1073
1102
|
export function arrangeCanvasNodes(layout: CanvasArrangeMode): { arranged: number; layout: CanvasArrangeMode } {
|
|
1074
1103
|
const nodes = canvasState.getLayout().nodes;
|
|
1075
|
-
const excludedIds =
|
|
1104
|
+
const excludedIds = layout === 'grid'
|
|
1105
|
+
? collectGridArrangeExcludedNodeIds(nodes)
|
|
1106
|
+
: collectArrangeExcludedNodeIds(nodes);
|
|
1076
1107
|
const movableNodes = nodes.filter((node) => !excludedIds.has(node.id));
|
|
1077
|
-
const gap = 24;
|
|
1078
1108
|
const oldPositions = nodes.map((node) => ({ id: node.id, position: { ...node.position } }));
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1109
|
+
const oldSizes = nodes.map((node) => ({ id: node.id, size: { ...node.size } }));
|
|
1110
|
+
const newSizesById = new Map<string, CanvasNodeUpdate['size']>();
|
|
1111
|
+
const oldSizesById = new Map(oldSizes.map((entry) => [entry.id, entry.size]));
|
|
1112
|
+
const updates: CanvasNodeUpdate[] = [];
|
|
1113
|
+
|
|
1114
|
+
if (layout === 'column' || layout === 'flow') {
|
|
1115
|
+
const gap = 24;
|
|
1116
|
+
let x = 40;
|
|
1117
|
+
let y = 80;
|
|
1118
|
+
for (const node of movableNodes) {
|
|
1119
|
+
updates.push({ id: node.id, position: { x, y } });
|
|
1120
|
+
if (layout === 'column') {
|
|
1085
1121
|
y += node.size.height + gap;
|
|
1086
|
-
}
|
|
1087
|
-
return;
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
if (layout === 'flow') {
|
|
1091
|
-
let x = 40;
|
|
1092
|
-
for (const node of movableNodes) {
|
|
1093
|
-
canvasState.updateNode(node.id, { position: { x, y: 80 } });
|
|
1122
|
+
} else {
|
|
1094
1123
|
x += node.size.width + gap;
|
|
1095
1124
|
}
|
|
1096
|
-
return;
|
|
1097
1125
|
}
|
|
1098
|
-
|
|
1099
|
-
const
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1102
|
-
let rowY = 80;
|
|
1103
|
-
let rowMaxHeight = 0;
|
|
1104
|
-
for (const node of movableNodes) {
|
|
1105
|
-
const x = 40 + col * (maxNodeWidth + gap);
|
|
1106
|
-
canvasState.updateNode(node.id, { position: { x, y: rowY } });
|
|
1107
|
-
rowMaxHeight = Math.max(rowMaxHeight, node.size.height);
|
|
1108
|
-
col++;
|
|
1109
|
-
if (col >= cols) {
|
|
1110
|
-
col = 0;
|
|
1111
|
-
rowY += rowMaxHeight + gap;
|
|
1112
|
-
rowMaxHeight = 0;
|
|
1113
|
-
}
|
|
1126
|
+
} else {
|
|
1127
|
+
const result = computeAutoArrange(movableNodes, canvasState.getEdges(), 'grid');
|
|
1128
|
+
for (const [id, position] of result.nodePositions.entries()) {
|
|
1129
|
+
updates.push({ id, position });
|
|
1114
1130
|
}
|
|
1131
|
+
for (const [groupId, bounds] of result.groupBounds.entries()) {
|
|
1132
|
+
updates.push({
|
|
1133
|
+
id: groupId,
|
|
1134
|
+
position: { x: bounds.x, y: bounds.y },
|
|
1135
|
+
size: { width: bounds.width, height: bounds.height },
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
canvasState.withSuppressedRecording(() => {
|
|
1141
|
+
canvasState.applyUpdates(updates, layout === 'grid' ? { skipGroupChildTranslation: true } : {});
|
|
1115
1142
|
});
|
|
1116
1143
|
|
|
1117
1144
|
const newPositions = nodes.map((node) => {
|
|
1118
1145
|
const updated = canvasState.getNode(node.id);
|
|
1119
1146
|
return { id: node.id, position: updated ? { ...updated.position } : { ...node.position } };
|
|
1120
1147
|
});
|
|
1148
|
+
for (const node of nodes) {
|
|
1149
|
+
const updated = canvasState.getNode(node.id);
|
|
1150
|
+
const size = updated ? { ...updated.size } : { ...node.size };
|
|
1151
|
+
newSizesById.set(node.id, size);
|
|
1152
|
+
}
|
|
1121
1153
|
mutationHistory.record({
|
|
1122
1154
|
description: `Auto-arranged ${movableNodes.length} nodes (${layout})`,
|
|
1123
1155
|
operationType: 'arrange',
|
|
1124
1156
|
forward: () => canvasState.withSuppressedRecording(() => {
|
|
1125
|
-
|
|
1157
|
+
canvasState.applyUpdates(newPositions.map((position) => {
|
|
1158
|
+
const size = newSizesById.get(position.id);
|
|
1159
|
+
return {
|
|
1160
|
+
id: position.id,
|
|
1161
|
+
position: position.position,
|
|
1162
|
+
...(size ? { size } : {}),
|
|
1163
|
+
};
|
|
1164
|
+
}), layout === 'grid' ? { skipGroupChildTranslation: true } : {});
|
|
1126
1165
|
}),
|
|
1127
1166
|
inverse: () => canvasState.withSuppressedRecording(() => {
|
|
1128
|
-
|
|
1167
|
+
canvasState.applyUpdates(oldPositions.map((position) => {
|
|
1168
|
+
const size = oldSizesById.get(position.id);
|
|
1169
|
+
return {
|
|
1170
|
+
id: position.id,
|
|
1171
|
+
position: position.position,
|
|
1172
|
+
...(size ? { size } : {}),
|
|
1173
|
+
};
|
|
1174
|
+
}), layout === 'grid' ? { skipGroupChildTranslation: true } : {});
|
|
1129
1175
|
}),
|
|
1130
1176
|
});
|
|
1131
1177
|
|
|
@@ -1477,6 +1523,9 @@ export async function executeCanvasBatch(
|
|
|
1477
1523
|
switch (operation.op) {
|
|
1478
1524
|
case 'node.add': {
|
|
1479
1525
|
const type = typeof args.type === 'string' ? args.type : 'markdown';
|
|
1526
|
+
if (type === 'html-primitive') {
|
|
1527
|
+
throw new Error('Batch html-primitive creation is not supported yet. Use node.add with type "html" and generated html, or create the primitive through MCP/HTTP/CLI first.');
|
|
1528
|
+
}
|
|
1480
1529
|
if (type === 'webpage') {
|
|
1481
1530
|
const created = addCanvasNode({
|
|
1482
1531
|
type: 'webpage',
|
|
@@ -1502,18 +1551,22 @@ export async function executeCanvasBatch(
|
|
|
1502
1551
|
...(fetch.ok ? {} : { error: fetch.error }),
|
|
1503
1552
|
};
|
|
1504
1553
|
} else {
|
|
1554
|
+
const data = isPlainRecord(args.data) ? args.data : {};
|
|
1555
|
+
const htmlData = type === 'html' && typeof args.html === 'string'
|
|
1556
|
+
? { ...data, html: args.html }
|
|
1557
|
+
: data;
|
|
1505
1558
|
const created = addCanvasNode({
|
|
1506
1559
|
type: type as CanvasNodeState['type'],
|
|
1507
1560
|
...(typeof args.title === 'string' ? { title: args.title } : {}),
|
|
1508
1561
|
...(typeof args.content === 'string' ? { content: args.content } : {}),
|
|
1509
|
-
...(
|
|
1562
|
+
...(Object.keys(htmlData).length > 0 ? { data: htmlData } : {}),
|
|
1510
1563
|
...(typeof args.x === 'number' ? { x: args.x } : {}),
|
|
1511
1564
|
...(typeof args.y === 'number' ? { y: args.y } : {}),
|
|
1512
1565
|
...(typeof args.width === 'number' ? { width: args.width } : {}),
|
|
1513
1566
|
...(typeof args.height === 'number' ? { height: args.height } : {}),
|
|
1514
1567
|
...(args.strictSize === true ? { strictSize: true } : {}),
|
|
1515
|
-
defaultWidth: 360,
|
|
1516
|
-
defaultHeight: 200,
|
|
1568
|
+
defaultWidth: type === 'html' ? 720 : type === 'markdown' ? MARKDOWN_NODE_DEFAULT_SIZE.width : 360,
|
|
1569
|
+
defaultHeight: type === 'html' ? 640 : type === 'markdown' ? MARKDOWN_NODE_DEFAULT_SIZE.height : 200,
|
|
1517
1570
|
fileMode: 'auto',
|
|
1518
1571
|
});
|
|
1519
1572
|
result = { ok: true, ...serializeCreatedNode(created.node) };
|
|
@@ -8,6 +8,12 @@ import {
|
|
|
8
8
|
type GraphNodeInput,
|
|
9
9
|
type JsonRenderSpec,
|
|
10
10
|
} from '../json-render/server.js';
|
|
11
|
+
import {
|
|
12
|
+
buildHtmlPrimitive,
|
|
13
|
+
isHtmlPrimitiveKind,
|
|
14
|
+
listHtmlPrimitiveDescriptors,
|
|
15
|
+
type HtmlPrimitiveDescriptor,
|
|
16
|
+
} from './html-primitives.js';
|
|
11
17
|
|
|
12
18
|
export interface CanvasCreateField {
|
|
13
19
|
name: string;
|
|
@@ -30,8 +36,14 @@ export interface CanvasCreateTypeSchema {
|
|
|
30
36
|
|
|
31
37
|
export interface StructuredValidationResult {
|
|
32
38
|
ok: true;
|
|
33
|
-
type: 'json-render' | 'graph';
|
|
34
|
-
normalizedSpec
|
|
39
|
+
type: 'json-render' | 'graph' | 'html-primitive';
|
|
40
|
+
normalizedSpec?: JsonRenderSpec;
|
|
41
|
+
normalizedPrimitive?: {
|
|
42
|
+
kind: string;
|
|
43
|
+
title: string;
|
|
44
|
+
htmlBytes: number;
|
|
45
|
+
defaultSize: { width: number; height: number };
|
|
46
|
+
};
|
|
35
47
|
summary: Record<string, unknown>;
|
|
36
48
|
}
|
|
37
49
|
|
|
@@ -231,6 +243,8 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
231
243
|
mcpTool: 'canvas_add_html_node',
|
|
232
244
|
fields: [
|
|
233
245
|
{ name: 'html', type: 'string', required: false, description: 'HTML document or fragment rendered in the sandboxed iframe.', aliases: ['content', 'stdin'] },
|
|
246
|
+
{ name: 'primitive', type: 'HtmlPrimitiveKind', required: false, description: 'Generate HTML from a built-in communication primitive instead of passing raw HTML.', aliases: ['kind'] },
|
|
247
|
+
{ name: 'data', type: 'record<string, unknown>', required: false, description: 'Primitive data when --primitive is used, or arbitrary node metadata.' },
|
|
234
248
|
{ name: 'title', type: 'string', required: false, description: 'Optional node title.' },
|
|
235
249
|
{ name: 'x', type: 'number', required: false, description: 'Optional X position.' },
|
|
236
250
|
{ name: 'y', type: 'number', required: false, description: 'Optional Y position.' },
|
|
@@ -245,9 +259,41 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
245
259
|
},
|
|
246
260
|
notes: [
|
|
247
261
|
'The CLI accepts --content as an alias and stores it as data.html so the renderer can load it.',
|
|
262
|
+
'Use `primitive` / `kind` with `data` to create reusable agent communication artifacts such as choice grids, plans, review sheets, explainers, and editors.',
|
|
248
263
|
'HTML runs in a sandboxed iframe without same-origin access to the canvas host.',
|
|
249
264
|
],
|
|
250
265
|
},
|
|
266
|
+
{
|
|
267
|
+
type: 'html-primitive',
|
|
268
|
+
kind: 'virtual-node',
|
|
269
|
+
description: 'Reusable sandboxed HTML communication primitive rendered as an html node.',
|
|
270
|
+
endpoint: '/api/canvas/node',
|
|
271
|
+
mcpTool: 'canvas_add_html_primitive',
|
|
272
|
+
fields: [
|
|
273
|
+
{ name: 'kind', type: 'HtmlPrimitiveKind', required: true, description: 'Primitive kind. See top-level htmlPrimitives for the supported catalog.' },
|
|
274
|
+
{ name: 'data', type: 'record<string, unknown>', required: false, description: 'Primitive-specific JSON object payload.' },
|
|
275
|
+
{ name: 'title', type: 'string', required: false, description: 'Optional node title.' },
|
|
276
|
+
{ name: 'x', type: 'number', required: false, description: 'Optional X position.' },
|
|
277
|
+
{ name: 'y', type: 'number', required: false, description: 'Optional Y position.' },
|
|
278
|
+
{ name: 'width', type: 'number', required: false, description: 'Optional node width; defaults per primitive.' },
|
|
279
|
+
{ name: 'height', type: 'number', required: false, description: 'Optional node height; defaults per primitive.' },
|
|
280
|
+
{ name: 'strictSize', type: 'boolean', required: false, description: 'Keep explicit width/height fixed and scroll overflowing content instead of browser auto-fitting.', aliases: ['strict-size', 'scroll-overflow'] },
|
|
281
|
+
],
|
|
282
|
+
example: {
|
|
283
|
+
type: 'html-primitive',
|
|
284
|
+
kind: 'choice-grid',
|
|
285
|
+
title: 'Implementation Options',
|
|
286
|
+
data: {
|
|
287
|
+
items: [
|
|
288
|
+
{ title: 'Small patch', summary: 'Least disruption.', pros: ['Fast'], cons: ['Limited flexibility'] },
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
notes: [
|
|
293
|
+
'HTTP callers may POST { type: "html-primitive", kind, data } or { type: "html", primitive: kind, data }; both create a normal html node with primitive metadata.',
|
|
294
|
+
'Interactive editor primitives include copy/export controls so the human can send edited state back to the agent.',
|
|
295
|
+
],
|
|
296
|
+
},
|
|
251
297
|
{
|
|
252
298
|
type: 'mcp-app',
|
|
253
299
|
kind: 'node',
|
|
@@ -449,6 +495,7 @@ export function describeCanvasSchema(): {
|
|
|
449
495
|
graph: {
|
|
450
496
|
graphTypes: CanvasGraphType[];
|
|
451
497
|
};
|
|
498
|
+
htmlPrimitives: HtmlPrimitiveDescriptor[];
|
|
452
499
|
mcp: {
|
|
453
500
|
tools: string[];
|
|
454
501
|
resources: string[];
|
|
@@ -472,9 +519,12 @@ export function describeCanvasSchema(): {
|
|
|
472
519
|
graph: {
|
|
473
520
|
graphTypes: [...CANONICAL_GRAPH_TYPES],
|
|
474
521
|
},
|
|
522
|
+
htmlPrimitives: listHtmlPrimitiveDescriptors(),
|
|
475
523
|
mcp: {
|
|
476
524
|
tools: [
|
|
477
525
|
'canvas_add_node',
|
|
526
|
+
'canvas_add_html_node',
|
|
527
|
+
'canvas_add_html_primitive',
|
|
478
528
|
'canvas_add_json_render_node',
|
|
479
529
|
'canvas_add_graph_node',
|
|
480
530
|
'canvas_build_web_artifact',
|
|
@@ -492,9 +542,10 @@ export function describeCanvasSchema(): {
|
|
|
492
542
|
}
|
|
493
543
|
|
|
494
544
|
export function validateStructuredCanvasPayload(input: {
|
|
495
|
-
type: 'json-render' | 'graph';
|
|
545
|
+
type: 'json-render' | 'graph' | 'html-primitive';
|
|
496
546
|
spec?: unknown;
|
|
497
547
|
graph?: GraphNodeInput;
|
|
548
|
+
primitive?: { kind: string; title?: string; data?: Record<string, unknown> };
|
|
498
549
|
}): StructuredValidationResult {
|
|
499
550
|
if (input.type === 'json-render') {
|
|
500
551
|
const normalizedSpec = normalizeAndValidateJsonRenderSpec(input.spec);
|
|
@@ -510,6 +561,35 @@ export function validateStructuredCanvasPayload(input: {
|
|
|
510
561
|
};
|
|
511
562
|
}
|
|
512
563
|
|
|
564
|
+
if (input.type === 'html-primitive') {
|
|
565
|
+
if (!input.primitive) {
|
|
566
|
+
throw new Error('HTML primitive validation requires a primitive payload.');
|
|
567
|
+
}
|
|
568
|
+
if (!isHtmlPrimitiveKind(input.primitive.kind)) {
|
|
569
|
+
throw new Error(`Unknown HTML primitive: ${input.primitive.kind}`);
|
|
570
|
+
}
|
|
571
|
+
const built = buildHtmlPrimitive({
|
|
572
|
+
kind: input.primitive.kind,
|
|
573
|
+
...(typeof input.primitive.title === 'string' ? { title: input.primitive.title } : {}),
|
|
574
|
+
...(input.primitive.data ? { data: input.primitive.data } : {}),
|
|
575
|
+
});
|
|
576
|
+
return {
|
|
577
|
+
ok: true,
|
|
578
|
+
type: 'html-primitive',
|
|
579
|
+
normalizedPrimitive: {
|
|
580
|
+
kind: built.kind,
|
|
581
|
+
title: built.title,
|
|
582
|
+
htmlBytes: Buffer.byteLength(built.html, 'utf-8'),
|
|
583
|
+
defaultSize: built.defaultSize,
|
|
584
|
+
},
|
|
585
|
+
summary: {
|
|
586
|
+
kind: built.kind,
|
|
587
|
+
title: built.title,
|
|
588
|
+
dataKeys: Object.keys(built.data),
|
|
589
|
+
},
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
513
593
|
if (!input.graph) {
|
|
514
594
|
throw new Error('Graph validation requires a graph payload.');
|
|
515
595
|
}
|
|
@@ -27,6 +27,7 @@ export interface CanvasAnnotationSummary {
|
|
|
27
27
|
color: string;
|
|
28
28
|
width: number;
|
|
29
29
|
pointCount: number;
|
|
30
|
+
text: string | null;
|
|
30
31
|
label: string | null;
|
|
31
32
|
createdAt: string;
|
|
32
33
|
}
|
|
@@ -75,6 +76,11 @@ export function getCanvasNodeTitle(node: CanvasNodeState): string | null {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
export function getCanvasNodeContent(node: CanvasNodeState): string | null {
|
|
79
|
+
if (node.type === 'html' && typeof node.data.htmlPrimitive === 'string') {
|
|
80
|
+
const primitive = node.data.htmlPrimitive;
|
|
81
|
+
const description = pickString(node.data.description);
|
|
82
|
+
return description ? `${primitive}: ${description}` : primitive;
|
|
83
|
+
}
|
|
78
84
|
return pickString(node.data.content)
|
|
79
85
|
?? pickString(node.data.fileContent)
|
|
80
86
|
?? pickString(node.data.text)
|
|
@@ -180,7 +186,8 @@ export function summarizeCanvasAnnotation(annotation: CanvasAnnotation): CanvasA
|
|
|
180
186
|
color: annotation.color,
|
|
181
187
|
width: annotation.width,
|
|
182
188
|
pointCount: annotation.points.length,
|
|
183
|
-
|
|
189
|
+
text: annotation.text ?? null,
|
|
190
|
+
label: annotation.label ?? annotation.text ?? null,
|
|
184
191
|
createdAt: annotation.createdAt,
|
|
185
192
|
};
|
|
186
193
|
}
|
|
@@ -208,7 +215,7 @@ export function summarizeCanvasAnnotationForContext(
|
|
|
208
215
|
const targetNodeTitles = targetNodes.map((node) => getCanvasNodeTitle(node) ?? node.id);
|
|
209
216
|
return {
|
|
210
217
|
id: annotation.id,
|
|
211
|
-
label: annotation.label ?? null,
|
|
218
|
+
label: annotation.label ?? annotation.text ?? null,
|
|
212
219
|
bounds: annotation.bounds,
|
|
213
220
|
targetNodeIds: targetNodes.map((node) => node.id),
|
|
214
221
|
targetNodeTitles,
|