pmx-canvas 0.1.15 → 0.1.17
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 +124 -0
- package/Readme.md +2 -2
- 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/nodes/ContextNode.d.ts +11 -2
- package/dist/types/client/nodes/StatusNode.d.ts +1 -0
- package/dist/types/client/state/canvas-store.d.ts +22 -3
- package/dist/types/client/state/intent-bridge.d.ts +2 -0
- 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 +23 -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 +17 -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 +3 -1
- package/src/client/canvas/DockedNode.tsx +4 -3
- package/src/client/canvas/use-pan-zoom.ts +10 -5
- package/src/client/icons.tsx +22 -0
- package/src/client/nodes/ContextNode.tsx +128 -6
- package/src/client/nodes/StatusNode.tsx +16 -1
- package/src/client/nodes/StatusSummary.tsx +2 -1
- package/src/client/state/canvas-store.ts +65 -7
- package/src/client/state/intent-bridge.ts +5 -1
- package/src/client/state/sse-bridge.ts +36 -2
- 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 +35 -4
- package/src/server/canvas-schema.ts +25 -0
- package/src/server/canvas-serialization.ts +69 -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 +101 -3
- 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;
|
|
@@ -330,13 +375,20 @@ export function replaceViewport(next: ViewportState): void {
|
|
|
330
375
|
}
|
|
331
376
|
|
|
332
377
|
export function commitViewport(next: ViewportState): void {
|
|
378
|
+
commitViewportWithOptions(next);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function commitViewportWithOptions(
|
|
382
|
+
next: ViewportState,
|
|
383
|
+
options: { recordHistory?: boolean } = {},
|
|
384
|
+
): void {
|
|
333
385
|
viewport.value = next;
|
|
334
|
-
persistLayout();
|
|
335
|
-
void updateViewportFromClient(next);
|
|
386
|
+
persistLayout(options);
|
|
387
|
+
void updateViewportFromClient(next, options);
|
|
336
388
|
}
|
|
337
389
|
|
|
338
390
|
export function applyServerCanvasLayout(
|
|
339
|
-
layout: Pick<CanvasLayout, 'nodes' | 'edges'> & { viewport?: ViewportState },
|
|
391
|
+
layout: Pick<CanvasLayout, 'nodes' | 'edges'> & { viewport?: ViewportState; annotations?: CanvasAnnotation[] },
|
|
340
392
|
options: { applyViewport?: boolean } = {},
|
|
341
393
|
): void {
|
|
342
394
|
const nextNodes = new Map<string, CanvasNodeState>();
|
|
@@ -353,6 +405,10 @@ export function applyServerCanvasLayout(
|
|
|
353
405
|
for (const edge of edgeSource) {
|
|
354
406
|
nextEdges.set(edge.id, edge);
|
|
355
407
|
}
|
|
408
|
+
const nextAnnotations = new Map<string, CanvasAnnotation>();
|
|
409
|
+
for (const annotation of layout.annotations ?? []) {
|
|
410
|
+
nextAnnotations.set(annotation.id, annotation);
|
|
411
|
+
}
|
|
356
412
|
|
|
357
413
|
const nextActiveNodeId =
|
|
358
414
|
activeNodeId.value !== null && nextNodes.has(activeNodeId.value) ? activeNodeId.value : null;
|
|
@@ -368,6 +424,7 @@ export function applyServerCanvasLayout(
|
|
|
368
424
|
maxZ = nextMaxZ;
|
|
369
425
|
nodes.value = nextNodes;
|
|
370
426
|
edges.value = nextEdges;
|
|
427
|
+
annotations.value = nextAnnotations;
|
|
371
428
|
activeNodeId.value = nextActiveNodeId;
|
|
372
429
|
expandedNodeId.value = nextExpandedNodeId;
|
|
373
430
|
if (!sameSetValues(selectedNodeIds.value, nextSelectedNodeIds)) {
|
|
@@ -394,6 +451,7 @@ function easeOutCubic(t: number): number {
|
|
|
394
451
|
export function animateViewport(
|
|
395
452
|
target: ViewportState,
|
|
396
453
|
duration = 300,
|
|
454
|
+
options: { recordHistory?: boolean } = {},
|
|
397
455
|
): void {
|
|
398
456
|
if (animationId !== null) cancelAnimationFrame(animationId);
|
|
399
457
|
|
|
@@ -415,7 +473,7 @@ export function animateViewport(
|
|
|
415
473
|
animationId = requestAnimationFrame(tick);
|
|
416
474
|
} else {
|
|
417
475
|
animationId = null;
|
|
418
|
-
|
|
476
|
+
commitViewportWithOptions(target, options);
|
|
419
477
|
}
|
|
420
478
|
}
|
|
421
479
|
|
|
@@ -540,7 +598,7 @@ export function fitAll(containerW: number, containerH: number): void {
|
|
|
540
598
|
}
|
|
541
599
|
|
|
542
600
|
// ── Focus node ────────────────────────────────────────────────
|
|
543
|
-
export function focusNode(id: string): void {
|
|
601
|
+
export function focusNode(id: string, options: { recordHistory?: boolean } = {}): void {
|
|
544
602
|
const node = nodes.value.get(id);
|
|
545
603
|
if (!node) return;
|
|
546
604
|
const v = viewport.value;
|
|
@@ -550,7 +608,7 @@ export function focusNode(id: string): void {
|
|
|
550
608
|
x: window.innerWidth / 2 - cx * v.scale,
|
|
551
609
|
y: window.innerHeight / 2 - cy * v.scale,
|
|
552
610
|
scale: v.scale,
|
|
553
|
-
});
|
|
611
|
+
}, 300, options);
|
|
554
612
|
bringToFront(id);
|
|
555
613
|
}
|
|
556
614
|
|
|
@@ -231,11 +231,15 @@ export async function removeNodeFromClient(id: string): Promise<{ ok: boolean; r
|
|
|
231
231
|
/** Commit the current viewport to the authoritative server state. */
|
|
232
232
|
export async function updateViewportFromClient(
|
|
233
233
|
viewport: { x: number; y: number; scale: number },
|
|
234
|
+
options: { recordHistory?: boolean } = {},
|
|
234
235
|
): Promise<{ ok: boolean }> {
|
|
235
236
|
return requestJson('updateViewportFromClient', '/api/canvas/viewport', { ok: false }, {
|
|
236
237
|
method: 'POST',
|
|
237
238
|
headers: { 'Content-Type': 'application/json' },
|
|
238
|
-
body: JSON.stringify(
|
|
239
|
+
body: JSON.stringify({
|
|
240
|
+
...viewport,
|
|
241
|
+
...(options.recordHistory === false ? { recordHistory: false } : {}),
|
|
242
|
+
}),
|
|
239
243
|
});
|
|
240
244
|
}
|
|
241
245
|
|
|
@@ -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,
|
|
@@ -229,7 +229,7 @@ function ensureExtAppNode(data: Record<string, unknown>): void {
|
|
|
229
229
|
});
|
|
230
230
|
addNode(node);
|
|
231
231
|
if (!node.dockPosition) {
|
|
232
|
-
focusNode(id);
|
|
232
|
+
focusNode(id, { recordHistory: false });
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
235
|
|
|
@@ -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,7 @@ 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 { getCanvasNodeTitle, serializeCanvasLayout, serializeCanvasNode } from '../server/canvas-serialization.js';
|
|
32
|
+
import { getCanvasNodeTitle, serializeCanvasLayout, serializeCanvasNode, summarizeCanvasAnnotationForContext } from '../server/canvas-serialization.js';
|
|
33
33
|
import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js';
|
|
34
34
|
|
|
35
35
|
let canvas: CanvasAccess | null = null;
|
|
@@ -191,6 +191,7 @@ function compactLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout
|
|
|
191
191
|
return {
|
|
192
192
|
summary: buildSummaryFromLayout(layout, pinnedIds),
|
|
193
193
|
viewport: layout.viewport,
|
|
194
|
+
annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
|
|
194
195
|
nodes: layout.nodes.map((node) => compactNodePayload(node)).filter((node): node is Record<string, unknown> => node !== null),
|
|
195
196
|
edges: layout.edges.map((edge) => ({
|
|
196
197
|
id: edge.id,
|
|
@@ -204,6 +205,13 @@ function compactLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout
|
|
|
204
205
|
};
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
function agentSafeFullLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout']>>): Record<string, unknown> {
|
|
209
|
+
return {
|
|
210
|
+
...serializeCanvasLayout(layout),
|
|
211
|
+
annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
207
215
|
function compactBatchValue(value: unknown): unknown {
|
|
208
216
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
|
|
209
217
|
const record = value as Record<string, unknown>;
|
|
@@ -248,6 +256,8 @@ function buildSummaryFromLayout(layout: Awaited<ReturnType<CanvasAccess['getLayo
|
|
|
248
256
|
return {
|
|
249
257
|
totalNodes: layout.nodes.length,
|
|
250
258
|
totalEdges: layout.edges.length,
|
|
259
|
+
totalAnnotations: (layout.annotations ?? []).length,
|
|
260
|
+
annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
|
|
251
261
|
nodesByType,
|
|
252
262
|
pinnedCount: pinned.size,
|
|
253
263
|
pinnedTitles,
|
|
@@ -263,6 +273,7 @@ function buildSnapshotRestoreSummary(layout: Awaited<ReturnType<CanvasAccess['ge
|
|
|
263
273
|
return {
|
|
264
274
|
nodeCount: layout.nodes.length,
|
|
265
275
|
edgeCount: layout.edges.length,
|
|
276
|
+
annotationCount: (layout.annotations ?? []).length,
|
|
266
277
|
nodesByType,
|
|
267
278
|
viewport: layout.viewport,
|
|
268
279
|
};
|
|
@@ -287,7 +298,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
287
298
|
const c = await ensureCanvas();
|
|
288
299
|
const layout = await c.getLayout();
|
|
289
300
|
const payload = wantsFullPayload(input)
|
|
290
|
-
?
|
|
301
|
+
? agentSafeFullLayoutPayload(layout)
|
|
291
302
|
: compactLayoutPayload(layout, await c.getPinnedNodeIds());
|
|
292
303
|
return {
|
|
293
304
|
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
@@ -902,6 +913,26 @@ export async function startMcpServer(): Promise<void> {
|
|
|
902
913
|
},
|
|
903
914
|
);
|
|
904
915
|
|
|
916
|
+
// ── canvas_remove_annotation ─────────────────────────────────────
|
|
917
|
+
server.tool(
|
|
918
|
+
'canvas_remove_annotation',
|
|
919
|
+
'Remove a human-drawn canvas annotation by ID.',
|
|
920
|
+
{ id: z.string().describe('Annotation ID to remove') },
|
|
921
|
+
async ({ id }) => {
|
|
922
|
+
const c = await ensureCanvas();
|
|
923
|
+
const removed = await c.removeAnnotation(id);
|
|
924
|
+
if (!removed) {
|
|
925
|
+
return {
|
|
926
|
+
content: [{ type: 'text', text: `Annotation "${id}" not found.` }],
|
|
927
|
+
isError: true,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
return {
|
|
931
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: true, removed: id }) }],
|
|
932
|
+
};
|
|
933
|
+
},
|
|
934
|
+
);
|
|
935
|
+
|
|
905
936
|
// ── canvas_add_edge ────────────────────────────────────────────
|
|
906
937
|
server.tool(
|
|
907
938
|
'canvas_add_edge',
|
|
@@ -1401,7 +1432,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
1401
1432
|
},
|
|
1402
1433
|
async () => {
|
|
1403
1434
|
const c = await ensureCanvas();
|
|
1404
|
-
const layout =
|
|
1435
|
+
const layout = agentSafeFullLayoutPayload(await c.getLayout());
|
|
1405
1436
|
return {
|
|
1406
1437
|
contents: [
|
|
1407
1438
|
{
|
|
@@ -1452,7 +1483,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
1452
1483
|
async () => {
|
|
1453
1484
|
const c = await ensureCanvas();
|
|
1454
1485
|
const layout = await c.getLayout();
|
|
1455
|
-
const spatial = buildSpatialContext(layout.nodes, layout.edges, new Set(await c.getPinnedNodeIds()));
|
|
1486
|
+
const spatial = buildSpatialContext(layout.nodes, layout.edges, new Set(await c.getPinnedNodeIds()), layout.annotations ?? []);
|
|
1456
1487
|
return {
|
|
1457
1488
|
contents: [
|
|
1458
1489
|
{
|
|
@@ -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,5 @@
|
|
|
1
1
|
import { canvasState } from './canvas-state.js';
|
|
2
|
-
import type { CanvasLayout, CanvasNodeState, ViewportState } from './canvas-state.js';
|
|
2
|
+
import type { CanvasAnnotation, CanvasLayout, CanvasNodeState, ViewportState } from './canvas-state.js';
|
|
3
3
|
import {
|
|
4
4
|
normalizeCanvasNodeData,
|
|
5
5
|
type CanvasNodeProvenance,
|
|
@@ -19,6 +19,26 @@ export interface SerializedCanvasLayout extends Omit<CanvasLayout, 'nodes'> {
|
|
|
19
19
|
nodes: SerializedCanvasNode[];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export interface CanvasAnnotationSummary {
|
|
23
|
+
id: string;
|
|
24
|
+
type: CanvasAnnotation['type'];
|
|
25
|
+
bounds: CanvasAnnotation['bounds'];
|
|
26
|
+
color: string;
|
|
27
|
+
width: number;
|
|
28
|
+
pointCount: number;
|
|
29
|
+
label: string | null;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CanvasAnnotationContextSummary {
|
|
34
|
+
id: string;
|
|
35
|
+
label: string | null;
|
|
36
|
+
bounds: CanvasAnnotation['bounds'];
|
|
37
|
+
targetNodeIds: string[];
|
|
38
|
+
targetNodeTitles: string[];
|
|
39
|
+
target: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
interface BlobSummary {
|
|
23
43
|
stored: 'sidecar';
|
|
24
44
|
path: string;
|
|
@@ -104,9 +124,55 @@ export function serializeCanvasLayoutWithBlobSummaries(layout: CanvasLayout): Se
|
|
|
104
124
|
};
|
|
105
125
|
}
|
|
106
126
|
|
|
127
|
+
export function summarizeCanvasAnnotation(annotation: CanvasAnnotation): CanvasAnnotationSummary {
|
|
128
|
+
return {
|
|
129
|
+
id: annotation.id,
|
|
130
|
+
type: annotation.type,
|
|
131
|
+
bounds: annotation.bounds,
|
|
132
|
+
color: annotation.color,
|
|
133
|
+
width: annotation.width,
|
|
134
|
+
pointCount: annotation.points.length,
|
|
135
|
+
label: annotation.label ?? null,
|
|
136
|
+
createdAt: annotation.createdAt,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function rectsOverlap(
|
|
141
|
+
a: { x: number; y: number; width: number; height: number },
|
|
142
|
+
b: { x: number; y: number; width: number; height: number },
|
|
143
|
+
): boolean {
|
|
144
|
+
return a.x <= b.x + b.width &&
|
|
145
|
+
a.x + a.width >= b.x &&
|
|
146
|
+
a.y <= b.y + b.height &&
|
|
147
|
+
a.y + a.height >= b.y;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function summarizeCanvasAnnotationForContext(
|
|
151
|
+
annotation: CanvasAnnotation,
|
|
152
|
+
nodes: CanvasNodeState[],
|
|
153
|
+
): CanvasAnnotationContextSummary {
|
|
154
|
+
const targetNodes = nodes.filter((node) => rectsOverlap(annotation.bounds, {
|
|
155
|
+
x: node.position.x,
|
|
156
|
+
y: node.position.y,
|
|
157
|
+
width: node.size.width,
|
|
158
|
+
height: node.size.height,
|
|
159
|
+
}));
|
|
160
|
+
const targetNodeTitles = targetNodes.map((node) => getCanvasNodeTitle(node) ?? node.id);
|
|
161
|
+
return {
|
|
162
|
+
id: annotation.id,
|
|
163
|
+
label: annotation.label ?? null,
|
|
164
|
+
bounds: annotation.bounds,
|
|
165
|
+
targetNodeIds: targetNodes.map((node) => node.id),
|
|
166
|
+
targetNodeTitles,
|
|
167
|
+
target: targetNodeTitles.length > 0 ? targetNodeTitles.join(', ') : 'empty canvas region',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
107
171
|
export interface CanvasSummary {
|
|
108
172
|
totalNodes: number;
|
|
109
173
|
totalEdges: number;
|
|
174
|
+
totalAnnotations: number;
|
|
175
|
+
annotations: CanvasAnnotationContextSummary[];
|
|
110
176
|
nodesByType: Record<string, number>;
|
|
111
177
|
pinnedCount: number;
|
|
112
178
|
pinnedTitles: string[];
|
|
@@ -130,6 +196,8 @@ export function buildCanvasSummary(): CanvasSummary {
|
|
|
130
196
|
return {
|
|
131
197
|
totalNodes: layout.nodes.length,
|
|
132
198
|
totalEdges: layout.edges.length,
|
|
199
|
+
totalAnnotations: layout.annotations.length,
|
|
200
|
+
annotations: layout.annotations.map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
|
|
133
201
|
nodesByType: typeCounts,
|
|
134
202
|
pinnedCount: pinnedIds.size,
|
|
135
203
|
pinnedTitles,
|