pmx-canvas 0.1.16 → 0.1.18
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 +110 -0
- package/Readme.md +14 -7
- package/dist/canvas/global.css +25 -0
- package/dist/canvas/index.js +72 -72
- package/dist/types/client/canvas/AnnotationLayer.d.ts +4 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +4 -1
- package/dist/types/client/canvas/use-pan-zoom.d.ts +2 -1
- package/dist/types/client/icons.d.ts +4 -0
- package/dist/types/client/state/canvas-store.d.ts +16 -1
- package/dist/types/client/types.d.ts +20 -0
- package/dist/types/mcp/canvas-access.d.ts +1 -0
- package/dist/types/server/canvas-serialization.d.ts +25 -1
- package/dist/types/server/canvas-state.d.ts +27 -1
- package/dist/types/server/index.d.ts +7 -2
- package/dist/types/server/mutation-history.d.ts +1 -1
- package/dist/types/server/spatial-analysis.d.ts +11 -2
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +19 -0
- package/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +145 -0
- package/src/cli/agent.ts +6 -0
- package/src/client/App.tsx +60 -3
- package/src/client/canvas/AnnotationLayer.tsx +28 -0
- package/src/client/canvas/CanvasViewport.tsx +169 -10
- package/src/client/canvas/ContextPinBar.tsx +2 -1
- package/src/client/canvas/use-pan-zoom.ts +10 -5
- package/src/client/icons.tsx +22 -0
- package/src/client/state/canvas-store.ts +52 -2
- package/src/client/state/sse-bridge.ts +35 -1
- package/src/client/theme/global.css +25 -0
- package/src/client/types.ts +17 -0
- package/src/mcp/canvas-access.ts +10 -0
- package/src/mcp/server.ts +43 -6
- package/src/server/canvas-schema.ts +25 -0
- package/src/server/canvas-serialization.ts +117 -1
- package/src/server/canvas-state.ts +74 -2
- package/src/server/diagram-presets.ts +54 -19
- package/src/server/index.ts +20 -3
- package/src/server/mutation-history.ts +2 -0
- package/src/server/server.ts +77 -2
- package/src/server/spatial-analysis.ts +46 -1
- package/src/shared/semantic-attention.ts +4 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { batch, computed, signal } from '@preact/signals';
|
|
2
|
-
import { isExcalidrawNode, type CanvasEdge, type CanvasLayout, type CanvasNodeState, type ConnectionStatus, type ViewportState } from '../types';
|
|
2
|
+
import { isExcalidrawNode, type CanvasAnnotation, type CanvasEdge, type CanvasLayout, type CanvasNodeState, type ConnectionStatus, type ViewportState } from '../types';
|
|
3
3
|
import { computeAutoArrange } from '../../shared/auto-arrange';
|
|
4
4
|
import { pushCanvasUpdate, updateViewportFromClient } from './intent-bridge';
|
|
5
5
|
|
|
@@ -11,6 +11,7 @@ function logCanvasStoreError(action: string, error: unknown): void {
|
|
|
11
11
|
export const viewport = signal<ViewportState>({ x: 0, y: 0, scale: 1 });
|
|
12
12
|
export const nodes = signal<Map<string, CanvasNodeState>>(new Map());
|
|
13
13
|
export const edges = signal<Map<string, CanvasEdge>>(new Map());
|
|
14
|
+
export const annotations = signal<Map<string, CanvasAnnotation>>(new Map());
|
|
14
15
|
export const activeNodeId = signal<string | null>(null);
|
|
15
16
|
export const connectionStatus = signal<ConnectionStatus>('connecting');
|
|
16
17
|
export const sessionId = signal<string>('');
|
|
@@ -258,6 +259,50 @@ export function removeEdgesForNode(nodeId: string): void {
|
|
|
258
259
|
if (changed) edges.value = next;
|
|
259
260
|
}
|
|
260
261
|
|
|
262
|
+
export function addAnnotation(annotation: CanvasAnnotation): void {
|
|
263
|
+
const next = new Map(annotations.value);
|
|
264
|
+
next.set(annotation.id, annotation);
|
|
265
|
+
annotations.value = next;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function removeAnnotation(id: string): void {
|
|
269
|
+
const next = new Map(annotations.value);
|
|
270
|
+
if (!next.delete(id)) return;
|
|
271
|
+
annotations.value = next;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function createAnnotationFromClient(input: {
|
|
275
|
+
points: CanvasAnnotation['points'];
|
|
276
|
+
color: string;
|
|
277
|
+
width: number;
|
|
278
|
+
label?: string;
|
|
279
|
+
}): Promise<{ ok: boolean }> {
|
|
280
|
+
try {
|
|
281
|
+
const res = await fetch('/api/canvas/annotation', {
|
|
282
|
+
method: 'POST',
|
|
283
|
+
headers: { 'Content-Type': 'application/json' },
|
|
284
|
+
body: JSON.stringify(input),
|
|
285
|
+
});
|
|
286
|
+
return { ok: res.ok };
|
|
287
|
+
} catch (error) {
|
|
288
|
+
logCanvasStoreError('createAnnotationFromClient', error);
|
|
289
|
+
return { ok: false };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function removeAnnotationFromClient(id: string): Promise<{ ok: boolean }> {
|
|
294
|
+
try {
|
|
295
|
+
const res = await fetch(`/api/canvas/annotation/${encodeURIComponent(id)}`, {
|
|
296
|
+
method: 'DELETE',
|
|
297
|
+
});
|
|
298
|
+
if (res.ok) removeAnnotation(id);
|
|
299
|
+
return { ok: res.ok };
|
|
300
|
+
} catch (error) {
|
|
301
|
+
logCanvasStoreError('removeAnnotationFromClient', error);
|
|
302
|
+
return { ok: false };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
261
306
|
export function resizeNode(id: string, size: { width: number; height: number }): void {
|
|
262
307
|
const existing = nodes.value.get(id);
|
|
263
308
|
if (!existing) return;
|
|
@@ -343,7 +388,7 @@ function commitViewportWithOptions(
|
|
|
343
388
|
}
|
|
344
389
|
|
|
345
390
|
export function applyServerCanvasLayout(
|
|
346
|
-
layout: Pick<CanvasLayout, 'nodes' | 'edges'> & { viewport?: ViewportState },
|
|
391
|
+
layout: Pick<CanvasLayout, 'nodes' | 'edges'> & { viewport?: ViewportState; annotations?: CanvasAnnotation[] },
|
|
347
392
|
options: { applyViewport?: boolean } = {},
|
|
348
393
|
): void {
|
|
349
394
|
const nextNodes = new Map<string, CanvasNodeState>();
|
|
@@ -360,6 +405,10 @@ export function applyServerCanvasLayout(
|
|
|
360
405
|
for (const edge of edgeSource) {
|
|
361
406
|
nextEdges.set(edge.id, edge);
|
|
362
407
|
}
|
|
408
|
+
const nextAnnotations = new Map<string, CanvasAnnotation>();
|
|
409
|
+
for (const annotation of layout.annotations ?? []) {
|
|
410
|
+
nextAnnotations.set(annotation.id, annotation);
|
|
411
|
+
}
|
|
363
412
|
|
|
364
413
|
const nextActiveNodeId =
|
|
365
414
|
activeNodeId.value !== null && nextNodes.has(activeNodeId.value) ? activeNodeId.value : null;
|
|
@@ -375,6 +424,7 @@ export function applyServerCanvasLayout(
|
|
|
375
424
|
maxZ = nextMaxZ;
|
|
376
425
|
nodes.value = nextNodes;
|
|
377
426
|
edges.value = nextEdges;
|
|
427
|
+
annotations.value = nextAnnotations;
|
|
378
428
|
activeNodeId.value = nextActiveNodeId;
|
|
379
429
|
expandedNodeId.value = nextExpandedNodeId;
|
|
380
430
|
if (!sameSetValues(selectedNodeIds.value, nextSelectedNodeIds)) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { findOpenCanvasPosition } from '../utils/placement.js';
|
|
2
2
|
import { normalizeExtAppToolResult } from '../utils/ext-app-tool-result.js';
|
|
3
|
-
import type { CanvasEdge, CanvasNodeState } from '../types';
|
|
3
|
+
import type { CanvasAnnotation, CanvasEdge, CanvasNodeState } from '../types';
|
|
4
4
|
import {
|
|
5
5
|
activeNodeId,
|
|
6
6
|
addEdge,
|
|
@@ -310,6 +310,7 @@ function isCanvasNodeType(value: unknown): value is CanvasNodeState['type'] {
|
|
|
310
310
|
|| value === 'trace'
|
|
311
311
|
|| value === 'file'
|
|
312
312
|
|| value === 'image'
|
|
313
|
+
|| value === 'html'
|
|
313
314
|
|| value === 'group';
|
|
314
315
|
}
|
|
315
316
|
|
|
@@ -334,6 +335,12 @@ function parseCanvasSize(value: unknown): { width: number; height: number } | nu
|
|
|
334
335
|
return { width: size.width, height: size.height };
|
|
335
336
|
}
|
|
336
337
|
|
|
338
|
+
function parseCanvasRect(value: unknown): { x: number; y: number; width: number; height: number } | null {
|
|
339
|
+
const position = parseCanvasPosition(value);
|
|
340
|
+
const size = parseCanvasSize(value);
|
|
341
|
+
return position && size ? { ...position, ...size } : null;
|
|
342
|
+
}
|
|
343
|
+
|
|
337
344
|
function parseCanvasNode(raw: Record<string, unknown>): CanvasNodeState | null {
|
|
338
345
|
if (typeof raw.id !== 'string' || !raw.id) return null;
|
|
339
346
|
if (!isCanvasNodeType(raw.type)) return null;
|
|
@@ -379,6 +386,28 @@ function parseCanvasEdge(raw: Record<string, unknown>): CanvasEdge | null {
|
|
|
379
386
|
};
|
|
380
387
|
}
|
|
381
388
|
|
|
389
|
+
function parseCanvasAnnotation(raw: Record<string, unknown>): CanvasAnnotation | null {
|
|
390
|
+
if (typeof raw.id !== 'string' || !raw.id) return null;
|
|
391
|
+
if (raw.type !== 'freehand') return null;
|
|
392
|
+
if (!Array.isArray(raw.points)) return null;
|
|
393
|
+
const points = raw.points
|
|
394
|
+
.map((point) => parseCanvasPosition(point))
|
|
395
|
+
.filter((point): point is { x: number; y: number } => point !== null);
|
|
396
|
+
const bounds = parseCanvasRect(raw.bounds);
|
|
397
|
+
if (points.length < 2 || !bounds) return null;
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
id: raw.id,
|
|
401
|
+
type: 'freehand',
|
|
402
|
+
points,
|
|
403
|
+
bounds,
|
|
404
|
+
color: typeof raw.color === 'string' ? raw.color : '#f97316',
|
|
405
|
+
width: typeof raw.width === 'number' ? raw.width : 4,
|
|
406
|
+
...(typeof raw.label === 'string' ? { label: raw.label } : {}),
|
|
407
|
+
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : '',
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
382
411
|
// ── SSE event handlers ───────────────────────────────────────
|
|
383
412
|
function handleConnected(data: Record<string, unknown>): void {
|
|
384
413
|
sessionId.value = (data.sessionId as string) || '';
|
|
@@ -801,6 +830,7 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
|
|
|
801
830
|
| {
|
|
802
831
|
nodes?: Array<Record<string, unknown>>;
|
|
803
832
|
edges?: Array<Record<string, unknown>>;
|
|
833
|
+
annotations?: Array<Record<string, unknown>>;
|
|
804
834
|
viewport?: Record<string, unknown>;
|
|
805
835
|
}
|
|
806
836
|
| undefined;
|
|
@@ -814,6 +844,9 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
|
|
|
814
844
|
const serverEdges = Array.isArray(layout.edges)
|
|
815
845
|
? layout.edges.map(parseCanvasEdge).filter((edge): edge is CanvasEdge => edge !== null)
|
|
816
846
|
: Array.from(edges.value.values());
|
|
847
|
+
const serverAnnotations = Array.isArray(layout.annotations)
|
|
848
|
+
? layout.annotations.map(parseCanvasAnnotation).filter((annotation): annotation is CanvasAnnotation => annotation !== null)
|
|
849
|
+
: undefined;
|
|
817
850
|
const nextViewport = layout.viewport
|
|
818
851
|
? {
|
|
819
852
|
x: typeof layout.viewport.x === 'number' ? layout.viewport.x : 0,
|
|
@@ -827,6 +860,7 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
|
|
|
827
860
|
...(nextViewport ? { viewport: nextViewport } : {}),
|
|
828
861
|
nodes: serverNodes,
|
|
829
862
|
edges: serverEdges,
|
|
863
|
+
...(serverAnnotations ? { annotations: serverAnnotations } : {}),
|
|
830
864
|
}, { applyViewport: shouldApplyViewport });
|
|
831
865
|
|
|
832
866
|
syncAttentionFromSse({ event: 'canvas-layout-update', data });
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
--c-accent-hover: #6ECAFF;
|
|
51
51
|
--c-warn-hover: #f5d06b;
|
|
52
52
|
--c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(0, 0, 0, 0));
|
|
53
|
+
--c-annotation: #F4EFE6;
|
|
53
54
|
/* ── Non-color tokens ────────────────────────────────────── */
|
|
54
55
|
--font: "IBM Plex Sans", "SF Pro Text", "Avenir Next", system-ui, sans-serif;
|
|
55
56
|
--mono: "IBM Plex Mono", "SF Mono", "Fira Code", monospace;
|
|
@@ -109,6 +110,7 @@
|
|
|
109
110
|
--c-accent-hover: #1588CE;
|
|
110
111
|
--c-warn-hover: #dab040;
|
|
111
112
|
--c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(8, 21, 36, 0.02));
|
|
113
|
+
--c-annotation: #081524;
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
:root[data-theme="high-contrast"] {
|
|
@@ -162,6 +164,7 @@
|
|
|
162
164
|
--c-accent-hover: #33ffff;
|
|
163
165
|
--c-warn-hover: #ffff33;
|
|
164
166
|
--c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0));
|
|
167
|
+
--c-annotation: #ffff00;
|
|
165
168
|
}
|
|
166
169
|
|
|
167
170
|
* {
|
|
@@ -1471,6 +1474,28 @@ body,
|
|
|
1471
1474
|
z-index: 9998;
|
|
1472
1475
|
}
|
|
1473
1476
|
|
|
1477
|
+
.annotation-layer {
|
|
1478
|
+
position: absolute;
|
|
1479
|
+
inset: 0;
|
|
1480
|
+
width: 1px;
|
|
1481
|
+
height: 1px;
|
|
1482
|
+
overflow: visible;
|
|
1483
|
+
pointer-events: none;
|
|
1484
|
+
z-index: 45;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
.annotation-capture-layer {
|
|
1488
|
+
position: absolute;
|
|
1489
|
+
inset: 0;
|
|
1490
|
+
z-index: 9996;
|
|
1491
|
+
pointer-events: none;
|
|
1492
|
+
background: color-mix(in srgb, var(--c-accent) 5%, transparent);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
.annotation-capture-layer.erasing {
|
|
1496
|
+
background: color-mix(in srgb, var(--c-danger) 6%, transparent);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1474
1499
|
/* ── Drop Zone (file drag-and-drop) ─────────────────────────── */
|
|
1475
1500
|
.drop-zone-overlay {
|
|
1476
1501
|
position: absolute;
|
package/src/client/types.ts
CHANGED
|
@@ -41,6 +41,22 @@ export interface CanvasEdge {
|
|
|
41
41
|
animated?: boolean;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
export interface CanvasAnnotationPoint {
|
|
45
|
+
x: number;
|
|
46
|
+
y: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CanvasAnnotation {
|
|
50
|
+
id: string;
|
|
51
|
+
type: 'freehand';
|
|
52
|
+
points: CanvasAnnotationPoint[];
|
|
53
|
+
bounds: { x: number; y: number; width: number; height: number };
|
|
54
|
+
color: string;
|
|
55
|
+
width: number;
|
|
56
|
+
label?: string;
|
|
57
|
+
createdAt: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected';
|
|
45
61
|
|
|
46
62
|
// ── Shared constants for node type display ──────────────────
|
|
@@ -93,4 +109,5 @@ export interface CanvasLayout {
|
|
|
93
109
|
viewport: ViewportState;
|
|
94
110
|
nodes: CanvasNodeState[];
|
|
95
111
|
edges: CanvasEdge[];
|
|
112
|
+
annotations?: CanvasAnnotation[];
|
|
96
113
|
}
|
package/src/mcp/canvas-access.ts
CHANGED
|
@@ -106,6 +106,7 @@ export interface CanvasAccess {
|
|
|
106
106
|
buildWebArtifact(input: WebArtifactInput): Promise<WebArtifactResult>;
|
|
107
107
|
updateNode(id: string, patch: UpdateNodePatch): Promise<void>;
|
|
108
108
|
removeNode(id: string): Promise<void>;
|
|
109
|
+
removeAnnotation(id: string): Promise<boolean>;
|
|
109
110
|
addEdge(input: AddEdgeInput): Promise<string>;
|
|
110
111
|
removeEdge(id: string): Promise<void>;
|
|
111
112
|
createGroup(input: CreateGroupInput): Promise<string>;
|
|
@@ -203,6 +204,10 @@ class LocalCanvasAccess implements CanvasAccess {
|
|
|
203
204
|
this.canvas.removeNode(id);
|
|
204
205
|
}
|
|
205
206
|
|
|
207
|
+
async removeAnnotation(id: string): Promise<boolean> {
|
|
208
|
+
return this.canvas.removeAnnotation(id);
|
|
209
|
+
}
|
|
210
|
+
|
|
206
211
|
async addEdge(input: AddEdgeInput): Promise<string> {
|
|
207
212
|
return this.canvas.addEdge(input);
|
|
208
213
|
}
|
|
@@ -455,6 +460,11 @@ class RemoteCanvasAccess implements CanvasAccess {
|
|
|
455
460
|
await this.requestJson<unknown>('DELETE', `/api/canvas/node/${encodeURIComponent(id)}`);
|
|
456
461
|
}
|
|
457
462
|
|
|
463
|
+
async removeAnnotation(id: string): Promise<boolean> {
|
|
464
|
+
const response = await this.requestJson<{ ok?: boolean }>('DELETE', `/api/canvas/annotation/${encodeURIComponent(id)}`);
|
|
465
|
+
return response.ok === true;
|
|
466
|
+
}
|
|
467
|
+
|
|
458
468
|
async addEdge(input: AddEdgeInput): Promise<string> {
|
|
459
469
|
const response = await this.requestJson<{ id?: string }>('POST', '/api/canvas/edge', input);
|
|
460
470
|
if (!response.id) throw new Error('Canvas edge response did not include an edge id.');
|
package/src/mcp/server.ts
CHANGED
|
@@ -29,7 +29,13 @@ import { createCanvasAccess, refreshCanvasAccess, type CanvasAccess } from './ca
|
|
|
29
29
|
import { serializeNodeForAgentContext } from '../server/agent-context.js';
|
|
30
30
|
import { wrapCanvasAutomationScript } from '../server/server.js';
|
|
31
31
|
import { buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
getCanvasNodeTitle,
|
|
34
|
+
serializeCanvasLayoutForAgent,
|
|
35
|
+
serializeCanvasNode,
|
|
36
|
+
serializeCanvasNodeForAgent,
|
|
37
|
+
summarizeCanvasAnnotationForContext,
|
|
38
|
+
} from '../server/canvas-serialization.js';
|
|
33
39
|
import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js';
|
|
34
40
|
|
|
35
41
|
let canvas: CanvasAccess | null = null;
|
|
@@ -191,6 +197,7 @@ function compactLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout
|
|
|
191
197
|
return {
|
|
192
198
|
summary: buildSummaryFromLayout(layout, pinnedIds),
|
|
193
199
|
viewport: layout.viewport,
|
|
200
|
+
annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
|
|
194
201
|
nodes: layout.nodes.map((node) => compactNodePayload(node)).filter((node): node is Record<string, unknown> => node !== null),
|
|
195
202
|
edges: layout.edges.map((edge) => ({
|
|
196
203
|
id: edge.id,
|
|
@@ -204,6 +211,13 @@ function compactLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout
|
|
|
204
211
|
};
|
|
205
212
|
}
|
|
206
213
|
|
|
214
|
+
function agentSafeFullLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout']>>): Record<string, unknown> {
|
|
215
|
+
return {
|
|
216
|
+
...serializeCanvasLayoutForAgent(layout),
|
|
217
|
+
annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
207
221
|
function compactBatchValue(value: unknown): unknown {
|
|
208
222
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
|
|
209
223
|
const record = value as Record<string, unknown>;
|
|
@@ -232,7 +246,7 @@ async function createdNodePayload(c: CanvasAccess, id: string, options: { full?:
|
|
|
232
246
|
if (!wantsFullPayload(options)) {
|
|
233
247
|
return { ok: true, node: compactNodePayload(node), id };
|
|
234
248
|
}
|
|
235
|
-
const serialized =
|
|
249
|
+
const serialized = serializeCanvasNodeForAgent(node);
|
|
236
250
|
return { ok: true, node: serialized, ...serialized };
|
|
237
251
|
}
|
|
238
252
|
|
|
@@ -248,6 +262,8 @@ function buildSummaryFromLayout(layout: Awaited<ReturnType<CanvasAccess['getLayo
|
|
|
248
262
|
return {
|
|
249
263
|
totalNodes: layout.nodes.length,
|
|
250
264
|
totalEdges: layout.edges.length,
|
|
265
|
+
totalAnnotations: (layout.annotations ?? []).length,
|
|
266
|
+
annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
|
|
251
267
|
nodesByType,
|
|
252
268
|
pinnedCount: pinned.size,
|
|
253
269
|
pinnedTitles,
|
|
@@ -263,6 +279,7 @@ function buildSnapshotRestoreSummary(layout: Awaited<ReturnType<CanvasAccess['ge
|
|
|
263
279
|
return {
|
|
264
280
|
nodeCount: layout.nodes.length,
|
|
265
281
|
edgeCount: layout.edges.length,
|
|
282
|
+
annotationCount: (layout.annotations ?? []).length,
|
|
266
283
|
nodesByType,
|
|
267
284
|
viewport: layout.viewport,
|
|
268
285
|
};
|
|
@@ -287,7 +304,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
287
304
|
const c = await ensureCanvas();
|
|
288
305
|
const layout = await c.getLayout();
|
|
289
306
|
const payload = wantsFullPayload(input)
|
|
290
|
-
?
|
|
307
|
+
? agentSafeFullLayoutPayload(layout)
|
|
291
308
|
: compactLayoutPayload(layout, await c.getPinnedNodeIds());
|
|
292
309
|
return {
|
|
293
310
|
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
@@ -313,7 +330,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
313
330
|
isError: true,
|
|
314
331
|
};
|
|
315
332
|
}
|
|
316
|
-
const payload = wantsFullPayload(input) ?
|
|
333
|
+
const payload = wantsFullPayload(input) ? serializeCanvasNodeForAgent(node) : compactNodePayload(node);
|
|
317
334
|
return {
|
|
318
335
|
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
319
336
|
};
|
|
@@ -902,6 +919,26 @@ export async function startMcpServer(): Promise<void> {
|
|
|
902
919
|
},
|
|
903
920
|
);
|
|
904
921
|
|
|
922
|
+
// ── canvas_remove_annotation ─────────────────────────────────────
|
|
923
|
+
server.tool(
|
|
924
|
+
'canvas_remove_annotation',
|
|
925
|
+
'Remove a human-drawn canvas annotation by ID.',
|
|
926
|
+
{ id: z.string().describe('Annotation ID to remove') },
|
|
927
|
+
async ({ id }) => {
|
|
928
|
+
const c = await ensureCanvas();
|
|
929
|
+
const removed = await c.removeAnnotation(id);
|
|
930
|
+
if (!removed) {
|
|
931
|
+
return {
|
|
932
|
+
content: [{ type: 'text', text: `Annotation "${id}" not found.` }],
|
|
933
|
+
isError: true,
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
return {
|
|
937
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: true, removed: id }) }],
|
|
938
|
+
};
|
|
939
|
+
},
|
|
940
|
+
);
|
|
941
|
+
|
|
905
942
|
// ── canvas_add_edge ────────────────────────────────────────────
|
|
906
943
|
server.tool(
|
|
907
944
|
'canvas_add_edge',
|
|
@@ -1401,7 +1438,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
1401
1438
|
},
|
|
1402
1439
|
async () => {
|
|
1403
1440
|
const c = await ensureCanvas();
|
|
1404
|
-
const layout =
|
|
1441
|
+
const layout = agentSafeFullLayoutPayload(await c.getLayout());
|
|
1405
1442
|
return {
|
|
1406
1443
|
contents: [
|
|
1407
1444
|
{
|
|
@@ -1452,7 +1489,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
1452
1489
|
async () => {
|
|
1453
1490
|
const c = await ensureCanvas();
|
|
1454
1491
|
const layout = await c.getLayout();
|
|
1455
|
-
const spatial = buildSpatialContext(layout.nodes, layout.edges, new Set(await c.getPinnedNodeIds()));
|
|
1492
|
+
const spatial = buildSpatialContext(layout.nodes, layout.edges, new Set(await c.getPinnedNodeIds()), layout.annotations ?? []);
|
|
1456
1493
|
return {
|
|
1457
1494
|
contents: [
|
|
1458
1495
|
{
|
|
@@ -223,6 +223,31 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
223
223
|
'Webpage nodes persist `data.provenance` with the source URL and refresh strategy so reopened snapshots can be re-fetched.',
|
|
224
224
|
],
|
|
225
225
|
},
|
|
226
|
+
{
|
|
227
|
+
type: 'html',
|
|
228
|
+
kind: 'node',
|
|
229
|
+
description: 'Sandboxed iframe node rendered from inline HTML.',
|
|
230
|
+
endpoint: '/api/canvas/node',
|
|
231
|
+
mcpTool: 'canvas_add_html_node',
|
|
232
|
+
fields: [
|
|
233
|
+
{ name: 'html', type: 'string', required: false, description: 'HTML document or fragment rendered in the sandboxed iframe.', aliases: ['content', 'stdin'] },
|
|
234
|
+
{ name: 'title', type: 'string', required: false, description: 'Optional node title.' },
|
|
235
|
+
{ name: 'x', type: 'number', required: false, description: 'Optional X position.' },
|
|
236
|
+
{ name: 'y', type: 'number', required: false, description: 'Optional Y position.' },
|
|
237
|
+
{ name: 'width', type: 'number', required: false, description: 'Optional node width.' },
|
|
238
|
+
{ name: 'height', type: 'number', required: false, description: 'Optional node height.' },
|
|
239
|
+
{ 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'] },
|
|
240
|
+
],
|
|
241
|
+
example: {
|
|
242
|
+
type: 'html',
|
|
243
|
+
title: 'HTML Widget',
|
|
244
|
+
html: '<main><h1>Hello from PMX Canvas</h1></main>',
|
|
245
|
+
},
|
|
246
|
+
notes: [
|
|
247
|
+
'The CLI accepts --content as an alias and stores it as data.html so the renderer can load it.',
|
|
248
|
+
'HTML runs in a sandboxed iframe without same-origin access to the canvas host.',
|
|
249
|
+
],
|
|
250
|
+
},
|
|
226
251
|
{
|
|
227
252
|
type: 'mcp-app',
|
|
228
253
|
kind: 'node',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { canvasState } from './canvas-state.js';
|
|
2
|
-
import type { CanvasLayout, CanvasNodeState, ViewportState } from './canvas-state.js';
|
|
3
|
+
import type { CanvasAnnotation, CanvasLayout, CanvasNodeState, ViewportState } from './canvas-state.js';
|
|
3
4
|
import {
|
|
4
5
|
normalizeCanvasNodeData,
|
|
5
6
|
type CanvasNodeProvenance,
|
|
@@ -19,6 +20,26 @@ export interface SerializedCanvasLayout extends Omit<CanvasLayout, 'nodes'> {
|
|
|
19
20
|
nodes: SerializedCanvasNode[];
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
export interface CanvasAnnotationSummary {
|
|
24
|
+
id: string;
|
|
25
|
+
type: CanvasAnnotation['type'];
|
|
26
|
+
bounds: CanvasAnnotation['bounds'];
|
|
27
|
+
color: string;
|
|
28
|
+
width: number;
|
|
29
|
+
pointCount: number;
|
|
30
|
+
label: string | null;
|
|
31
|
+
createdAt: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CanvasAnnotationContextSummary {
|
|
35
|
+
id: string;
|
|
36
|
+
label: string | null;
|
|
37
|
+
bounds: CanvasAnnotation['bounds'];
|
|
38
|
+
targetNodeIds: string[];
|
|
39
|
+
targetNodeTitles: string[];
|
|
40
|
+
target: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
interface BlobSummary {
|
|
23
44
|
stored: 'sidecar';
|
|
24
45
|
path: string;
|
|
@@ -27,6 +48,13 @@ interface BlobSummary {
|
|
|
27
48
|
sha256: string;
|
|
28
49
|
}
|
|
29
50
|
|
|
51
|
+
interface ExternalMcpAppHtmlSummary {
|
|
52
|
+
omitted: 'external-mcp-app-html';
|
|
53
|
+
resourceUri: string;
|
|
54
|
+
bytes: number;
|
|
55
|
+
sha256: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
30
58
|
function pickString(value: unknown): string | null {
|
|
31
59
|
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
32
60
|
}
|
|
@@ -70,6 +98,39 @@ export function serializeCanvasNode(node: CanvasNodeState): SerializedCanvasNode
|
|
|
70
98
|
};
|
|
71
99
|
}
|
|
72
100
|
|
|
101
|
+
function summarizeExternalMcpAppHtml(node: SerializedCanvasNode): Record<string, unknown> {
|
|
102
|
+
const html = node.data.html;
|
|
103
|
+
const resourceUri = node.data.resourceUri;
|
|
104
|
+
if (
|
|
105
|
+
node.type !== 'mcp-app' ||
|
|
106
|
+
node.data.mode !== 'ext-app' ||
|
|
107
|
+
typeof html !== 'string' ||
|
|
108
|
+
html.length === 0 ||
|
|
109
|
+
typeof resourceUri !== 'string' ||
|
|
110
|
+
resourceUri.length === 0
|
|
111
|
+
) {
|
|
112
|
+
return node.data;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
...node.data,
|
|
117
|
+
html: {
|
|
118
|
+
omitted: 'external-mcp-app-html',
|
|
119
|
+
resourceUri,
|
|
120
|
+
bytes: Buffer.byteLength(html, 'utf-8'),
|
|
121
|
+
sha256: createHash('sha256').update(html).digest('hex'),
|
|
122
|
+
} satisfies ExternalMcpAppHtmlSummary,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function serializeCanvasNodeForAgent(node: CanvasNodeState): SerializedCanvasNode {
|
|
127
|
+
const serialized = serializeCanvasNode(node);
|
|
128
|
+
return {
|
|
129
|
+
...serialized,
|
|
130
|
+
data: summarizeExternalMcpAppHtml(serialized),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
73
134
|
function summarizeBlobValue(value: unknown): unknown {
|
|
74
135
|
if (!canvasState.isBlobReference(value)) return value;
|
|
75
136
|
return {
|
|
@@ -97,6 +158,13 @@ export function serializeCanvasLayout(layout: CanvasLayout): SerializedCanvasLay
|
|
|
97
158
|
};
|
|
98
159
|
}
|
|
99
160
|
|
|
161
|
+
export function serializeCanvasLayoutForAgent(layout: CanvasLayout): SerializedCanvasLayout {
|
|
162
|
+
return {
|
|
163
|
+
...layout,
|
|
164
|
+
nodes: layout.nodes.map(serializeCanvasNodeForAgent),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
100
168
|
export function serializeCanvasLayoutWithBlobSummaries(layout: CanvasLayout): SerializedCanvasLayout {
|
|
101
169
|
return {
|
|
102
170
|
...layout,
|
|
@@ -104,9 +172,55 @@ export function serializeCanvasLayoutWithBlobSummaries(layout: CanvasLayout): Se
|
|
|
104
172
|
};
|
|
105
173
|
}
|
|
106
174
|
|
|
175
|
+
export function summarizeCanvasAnnotation(annotation: CanvasAnnotation): CanvasAnnotationSummary {
|
|
176
|
+
return {
|
|
177
|
+
id: annotation.id,
|
|
178
|
+
type: annotation.type,
|
|
179
|
+
bounds: annotation.bounds,
|
|
180
|
+
color: annotation.color,
|
|
181
|
+
width: annotation.width,
|
|
182
|
+
pointCount: annotation.points.length,
|
|
183
|
+
label: annotation.label ?? null,
|
|
184
|
+
createdAt: annotation.createdAt,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function rectsOverlap(
|
|
189
|
+
a: { x: number; y: number; width: number; height: number },
|
|
190
|
+
b: { x: number; y: number; width: number; height: number },
|
|
191
|
+
): boolean {
|
|
192
|
+
return a.x <= b.x + b.width &&
|
|
193
|
+
a.x + a.width >= b.x &&
|
|
194
|
+
a.y <= b.y + b.height &&
|
|
195
|
+
a.y + a.height >= b.y;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function summarizeCanvasAnnotationForContext(
|
|
199
|
+
annotation: CanvasAnnotation,
|
|
200
|
+
nodes: CanvasNodeState[],
|
|
201
|
+
): CanvasAnnotationContextSummary {
|
|
202
|
+
const targetNodes = nodes.filter((node) => rectsOverlap(annotation.bounds, {
|
|
203
|
+
x: node.position.x,
|
|
204
|
+
y: node.position.y,
|
|
205
|
+
width: node.size.width,
|
|
206
|
+
height: node.size.height,
|
|
207
|
+
}));
|
|
208
|
+
const targetNodeTitles = targetNodes.map((node) => getCanvasNodeTitle(node) ?? node.id);
|
|
209
|
+
return {
|
|
210
|
+
id: annotation.id,
|
|
211
|
+
label: annotation.label ?? null,
|
|
212
|
+
bounds: annotation.bounds,
|
|
213
|
+
targetNodeIds: targetNodes.map((node) => node.id),
|
|
214
|
+
targetNodeTitles,
|
|
215
|
+
target: targetNodeTitles.length > 0 ? targetNodeTitles.join(', ') : 'empty canvas region',
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
107
219
|
export interface CanvasSummary {
|
|
108
220
|
totalNodes: number;
|
|
109
221
|
totalEdges: number;
|
|
222
|
+
totalAnnotations: number;
|
|
223
|
+
annotations: CanvasAnnotationContextSummary[];
|
|
110
224
|
nodesByType: Record<string, number>;
|
|
111
225
|
pinnedCount: number;
|
|
112
226
|
pinnedTitles: string[];
|
|
@@ -130,6 +244,8 @@ export function buildCanvasSummary(): CanvasSummary {
|
|
|
130
244
|
return {
|
|
131
245
|
totalNodes: layout.nodes.length,
|
|
132
246
|
totalEdges: layout.edges.length,
|
|
247
|
+
totalAnnotations: layout.annotations.length,
|
|
248
|
+
annotations: layout.annotations.map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
|
|
133
249
|
nodesByType: typeCounts,
|
|
134
250
|
pinnedCount: pinnedIds.size,
|
|
135
251
|
pinnedTitles,
|