pmx-canvas 0.1.2 → 0.1.4

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/Readme.md +35 -8
  3. package/dist/canvas/index.js +69 -69
  4. package/dist/json-render/index.css +1 -1
  5. package/dist/json-render/index.js +1 -1
  6. package/dist/types/client/nodes/ExtAppFrame.d.ts +12 -0
  7. package/dist/types/client/state/canvas-store.d.ts +2 -1
  8. package/dist/types/client/types.d.ts +3 -0
  9. package/dist/types/json-render/charts/components.d.ts +2 -1
  10. package/dist/types/server/canvas-serialization.d.ts +1 -0
  11. package/dist/types/server/diagram-presets.d.ts +13 -0
  12. package/dist/types/server/ext-app-lookup.d.ts +22 -0
  13. package/dist/types/server/index.d.ts +8 -1
  14. package/dist/types/server/web-artifacts.d.ts +1 -0
  15. package/package.json +2 -1
  16. package/skills/pmx-canvas/SKILL.md +35 -10
  17. package/skills/pmx-canvas/references/installing-pmx-canvas.md +66 -0
  18. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +10 -0
  19. package/skills/web-artifacts-builder/scripts/init-artifact.sh +1 -1
  20. package/src/cli/agent.ts +114 -21
  21. package/src/cli/index.ts +3 -1
  22. package/src/client/App.tsx +2 -1
  23. package/src/client/canvas/CanvasNode.tsx +3 -2
  24. package/src/client/canvas/ExpandedNodeOverlay.tsx +6 -1
  25. package/src/client/nodes/ExtAppFrame.tsx +97 -26
  26. package/src/client/state/canvas-store.ts +63 -1
  27. package/src/client/state/sse-bridge.ts +19 -4
  28. package/src/client/types.ts +12 -0
  29. package/src/json-render/charts/components.tsx +6 -4
  30. package/src/json-render/charts/extra-components.tsx +5 -5
  31. package/src/json-render/renderer/index.css +14 -0
  32. package/src/mcp/server.ts +44 -5
  33. package/src/server/canvas-operations.ts +43 -5
  34. package/src/server/canvas-schema.ts +16 -14
  35. package/src/server/canvas-serialization.ts +19 -1
  36. package/src/server/diagram-presets.ts +219 -4
  37. package/src/server/ext-app-lookup.ts +49 -0
  38. package/src/server/index.ts +33 -25
  39. package/src/server/server.ts +199 -45
  40. package/src/server/web-artifacts/scripts/bundle-artifact.sh +10 -0
  41. package/src/server/web-artifacts/scripts/init-artifact.sh +1 -1
  42. package/src/server/web-artifacts.ts +44 -1
@@ -1,8 +1,40 @@
1
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
1
2
  import type { ExternalMcpTransportConfig } from './mcp-app-runtime.js';
2
3
 
3
4
  export const EXCALIDRAW_MCP_URL = 'https://mcp.excalidraw.com/mcp';
4
5
  export const EXCALIDRAW_SERVER_NAME = 'Excalidraw';
5
6
  export const EXCALIDRAW_CREATE_VIEW_TOOL = 'create_view';
7
+ export const EXCALIDRAW_SAVE_CHECKPOINT_TOOL = 'save_checkpoint';
8
+ export const EXCALIDRAW_READ_CHECKPOINT_TOOL = 'read_checkpoint';
9
+ const EXCALIDRAW_CAMERA_PADDING = 80;
10
+ const EXCALIDRAW_MIN_CAMERA_WIDTH = 320;
11
+ const EXCALIDRAW_MIN_CAMERA_HEIGHT = 240;
12
+ const EXCALIDRAW_CAMERA_ASPECT_RATIO = 4 / 3;
13
+ const EXCALIDRAW_CAMERA_SIZES = [
14
+ { width: 400, height: 300 },
15
+ { width: 600, height: 450 },
16
+ { width: 800, height: 600 },
17
+ { width: 1200, height: 900 },
18
+ { width: 1600, height: 1200 },
19
+ ];
20
+
21
+ export const DEFAULT_EXCALIDRAW_ELEMENTS: ReadonlyArray<Record<string, unknown>> = [
22
+ {
23
+ type: 'rectangle',
24
+ id: 'pmx-start',
25
+ x: 80,
26
+ y: 80,
27
+ width: 280,
28
+ height: 120,
29
+ roundness: { type: 3 },
30
+ backgroundColor: '#a5d8ff',
31
+ fillStyle: 'solid',
32
+ label: {
33
+ text: 'PMX Canvas',
34
+ fontSize: 24,
35
+ },
36
+ },
37
+ ];
6
38
 
7
39
  export const EXCALIDRAW_MCP_TRANSPORT: ExternalMcpTransportConfig = {
8
40
  type: 'http',
@@ -30,7 +62,11 @@ export interface ExcalidrawOpenMcpAppInput {
30
62
  height?: number;
31
63
  }
32
64
 
33
- export function normalizeExcalidrawElements(elements: unknown): string {
65
+ function isRecord(value: unknown): value is Record<string, unknown> {
66
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
67
+ }
68
+
69
+ function parseExcalidrawElements(elements: unknown): Array<Record<string, unknown>> {
34
70
  if (typeof elements === 'string') {
35
71
  const trimmed = elements.trim();
36
72
  if (!trimmed) {
@@ -46,16 +82,195 @@ export function normalizeExcalidrawElements(elements: unknown): string {
46
82
  if (!Array.isArray(parsed)) {
47
83
  throw new Error('diagram.elements string must encode a JSON array.');
48
84
  }
49
- return JSON.stringify(parsed);
85
+ return parsed.filter(isRecord);
50
86
  }
87
+
51
88
  if (Array.isArray(elements)) {
52
- return JSON.stringify(elements);
89
+ return elements.filter(isRecord);
53
90
  }
91
+
54
92
  throw new Error('diagram.elements must be a JSON array string or an array of Excalidraw elements.');
55
93
  }
56
94
 
95
+ function parseExcalidrawCheckpointElements(data: unknown): Array<Record<string, unknown>> | null {
96
+ let parsed: unknown = data;
97
+ if (typeof data === 'string') {
98
+ try {
99
+ parsed = JSON.parse(data);
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ if (Array.isArray(parsed)) return parsed.filter(isRecord);
106
+ if (isRecord(parsed) && Array.isArray(parsed.elements)) return parsed.elements.filter(isRecord);
107
+ return null;
108
+ }
109
+
110
+ function finiteNumber(value: unknown): number | null {
111
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
112
+ }
113
+
114
+ function elementHasCameraUpdate(elements: Array<Record<string, unknown>>): boolean {
115
+ return elements.some((element) => element.type === 'cameraUpdate');
116
+ }
117
+
118
+ function resolveExcalidrawCameraSize(width: number, height: number): { width: number; height: number } {
119
+ const requiredWidth = Math.max(EXCALIDRAW_MIN_CAMERA_WIDTH, width);
120
+ const requiredHeight = Math.max(EXCALIDRAW_MIN_CAMERA_HEIGHT, height);
121
+ const standard = EXCALIDRAW_CAMERA_SIZES.find(
122
+ (size) => size.width >= requiredWidth && size.height >= requiredHeight,
123
+ );
124
+ if (standard) return standard;
125
+
126
+ const heightFromWidth = requiredWidth / EXCALIDRAW_CAMERA_ASPECT_RATIO;
127
+ const widthFromHeight = requiredHeight * EXCALIDRAW_CAMERA_ASPECT_RATIO;
128
+ const cameraWidth = Math.ceil(Math.max(requiredWidth, widthFromHeight));
129
+ return {
130
+ width: cameraWidth,
131
+ height: Math.ceil(cameraWidth / EXCALIDRAW_CAMERA_ASPECT_RATIO),
132
+ };
133
+ }
134
+
135
+ export function inferExcalidrawCameraUpdate(
136
+ elements: Array<Record<string, unknown>>,
137
+ ): Record<string, unknown> | null {
138
+ let minX = Number.POSITIVE_INFINITY;
139
+ let minY = Number.POSITIVE_INFINITY;
140
+ let maxX = Number.NEGATIVE_INFINITY;
141
+ let maxY = Number.NEGATIVE_INFINITY;
142
+
143
+ const includePoint = (x: number, y: number) => {
144
+ minX = Math.min(minX, x);
145
+ minY = Math.min(minY, y);
146
+ maxX = Math.max(maxX, x);
147
+ maxY = Math.max(maxY, y);
148
+ };
149
+
150
+ for (const element of elements) {
151
+ if (element.isDeleted === true || element.type === 'cameraUpdate' || element.type === 'restoreCheckpoint' || element.type === 'delete') {
152
+ continue;
153
+ }
154
+
155
+ const x = finiteNumber(element.x);
156
+ const y = finiteNumber(element.y);
157
+ if (x === null || y === null) continue;
158
+
159
+ includePoint(x, y);
160
+ const width = finiteNumber(element.width) ?? 0;
161
+ const height = finiteNumber(element.height) ?? 0;
162
+ includePoint(x + width, y + height);
163
+
164
+ if (Array.isArray(element.points)) {
165
+ for (const point of element.points) {
166
+ if (!Array.isArray(point)) continue;
167
+ const pointX = finiteNumber(point[0]);
168
+ const pointY = finiteNumber(point[1]);
169
+ if (pointX === null || pointY === null) continue;
170
+ includePoint(x + pointX, y + pointY);
171
+ }
172
+ }
173
+ }
174
+
175
+ if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
176
+ return null;
177
+ }
178
+
179
+ const contentWidth = Math.max(1, maxX - minX);
180
+ const contentHeight = Math.max(1, maxY - minY);
181
+ const padding = Math.max(
182
+ EXCALIDRAW_CAMERA_PADDING,
183
+ Math.round(Math.max(contentWidth, contentHeight) * 0.18),
184
+ );
185
+ const camera = resolveExcalidrawCameraSize(contentWidth + padding * 2, contentHeight + padding * 2);
186
+ const centerX = minX + contentWidth / 2;
187
+ const centerY = minY + contentHeight / 2;
188
+
189
+ return {
190
+ type: 'cameraUpdate',
191
+ x: Math.round(centerX - camera.width / 2),
192
+ y: Math.round(centerY - camera.height / 2),
193
+ width: camera.width,
194
+ height: camera.height,
195
+ };
196
+ }
197
+
198
+ function withInferredCameraUpdate(
199
+ elements: Array<Record<string, unknown>>,
200
+ ): Array<Record<string, unknown>> {
201
+ if (elementHasCameraUpdate(elements)) return elements;
202
+ const camera = inferExcalidrawCameraUpdate(elements);
203
+ return camera ? [camera, ...elements] : elements;
204
+ }
205
+
206
+ export function normalizeExcalidrawElements(elements: unknown): string {
207
+ const parsed = parseExcalidrawElements(elements);
208
+ return JSON.stringify(parsed.length > 0 ? parsed : DEFAULT_EXCALIDRAW_ELEMENTS);
209
+ }
210
+
211
+ export function normalizeExcalidrawElementsForToolInput(elements: unknown): string {
212
+ const parsed = parseExcalidrawElements(elements);
213
+ const seeded = parsed.length > 0 ? parsed : [...DEFAULT_EXCALIDRAW_ELEMENTS];
214
+ return JSON.stringify(withInferredCameraUpdate(seeded));
215
+ }
216
+
217
+ export function normalizeExcalidrawCheckpointDataForToolInput(data: unknown): string | null {
218
+ const elements = parseExcalidrawCheckpointElements(data);
219
+
220
+ return elements ? JSON.stringify(withInferredCameraUpdate(elements)) : null;
221
+ }
222
+
223
+ export function buildExcalidrawRestoreCheckpointToolInput(checkpointId: string, data?: unknown): string {
224
+ const elements = parseExcalidrawCheckpointElements(data);
225
+ const camera = elements ? inferExcalidrawCameraUpdate(elements) : null;
226
+ return JSON.stringify([
227
+ { type: 'restoreCheckpoint', id: checkpointId },
228
+ ...(camera ? [camera] : []),
229
+ ]);
230
+ }
231
+
232
+ export function isExcalidrawCreateView(serverName: unknown, toolName: unknown): boolean {
233
+ return serverName === EXCALIDRAW_SERVER_NAME && toolName === EXCALIDRAW_CREATE_VIEW_TOOL;
234
+ }
235
+
236
+ export function buildExcalidrawCheckpointId(seed: string): string {
237
+ const safe = seed.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 96);
238
+ return `pmx-${safe || 'checkpoint'}`;
239
+ }
240
+
241
+ export function getExcalidrawCheckpointIdFromToolResult(result: unknown): string | null {
242
+ if (!isRecord(result) || !isRecord(result.structuredContent)) return null;
243
+ const checkpointId = result.structuredContent.checkpointId;
244
+ return typeof checkpointId === 'string' && checkpointId.trim().length > 0 ? checkpointId.trim() : null;
245
+ }
246
+
247
+ export function withExcalidrawCheckpointId(
248
+ result: CallToolResult,
249
+ checkpointId: string,
250
+ ): CallToolResult {
251
+ const structuredContent = isRecord(result.structuredContent) ? result.structuredContent : {};
252
+ return {
253
+ ...result,
254
+ structuredContent: {
255
+ ...structuredContent,
256
+ checkpointId,
257
+ },
258
+ };
259
+ }
260
+
261
+ export function ensureExcalidrawCheckpointId(
262
+ result: CallToolResult,
263
+ seed: string,
264
+ checkpointId?: string | null,
265
+ ): CallToolResult {
266
+ return withExcalidrawCheckpointId(
267
+ result,
268
+ checkpointId ?? getExcalidrawCheckpointIdFromToolResult(result) ?? buildExcalidrawCheckpointId(seed),
269
+ );
270
+ }
271
+
57
272
  export function buildExcalidrawOpenMcpAppInput(input: DiagramPresetOpenInput): ExcalidrawOpenMcpAppInput {
58
- const elements = normalizeExcalidrawElements(input.elements);
273
+ const elements = normalizeExcalidrawElementsForToolInput(input.elements);
59
274
  const out: ExcalidrawOpenMcpAppInput = {
60
275
  transport: EXCALIDRAW_MCP_TRANSPORT,
61
276
  toolName: EXCALIDRAW_CREATE_VIEW_TOOL,
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Resolve the canvas node ID for a given ext-app `toolCallId`.
3
+ *
4
+ * v0.1.4 fixed a long-standing `ext-app-ext-app-…` double-prefix bug where
5
+ * both `nodeId` and `toolCallId` carried the `ext-app-` prefix. This helper
6
+ * encodes the lookup contract so it doesn't drift between the
7
+ * `PmxCanvas` SDK class and the HTTP server.
8
+ *
9
+ * Resolution order:
10
+ * 1. The direct prefixed form (`ext-app-<toolCallId>` if not already
11
+ * prefixed, otherwise `toolCallId` as-is).
12
+ * 2. The legacy `ext-app-ext-app-…` form, for canvases persisted before
13
+ * v0.1.4 and still on disk. Remove this fallback in v0.2.x.
14
+ * 3. A scan of the layout for any `mcp-app` ext-app node carrying that
15
+ * `toolCallId` in its data.
16
+ */
17
+ import type { CanvasNodeState } from './canvas-state.js';
18
+
19
+ export interface ExtAppLookupSource {
20
+ getNode(id: string): CanvasNodeState | undefined;
21
+ listNodes(): readonly CanvasNodeState[];
22
+ }
23
+
24
+ export function findCanvasExtAppNodeId(
25
+ toolCallId: string,
26
+ source: ExtAppLookupSource,
27
+ ): string | null {
28
+ const directId = toolCallId.startsWith('ext-app-')
29
+ ? toolCallId
30
+ : `ext-app-${toolCallId}`;
31
+ if (source.getNode(directId)) return directId;
32
+
33
+ const legacyDirectId = `ext-app-${toolCallId}`;
34
+ if (legacyDirectId !== directId && source.getNode(legacyDirectId)) {
35
+ return legacyDirectId;
36
+ }
37
+
38
+ for (const node of source.listNodes()) {
39
+ if (
40
+ node.type === 'mcp-app' &&
41
+ node.data.mode === 'ext-app' &&
42
+ node.data.toolCallId === toolCallId
43
+ ) {
44
+ return node.id;
45
+ }
46
+ }
47
+
48
+ return null;
49
+ }
@@ -1,6 +1,7 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import { canvasState, IMAGE_MIME_MAP } from './canvas-state.js';
3
3
  import type { CanvasNodeState, CanvasEdge, CanvasLayout, ViewportState } from './canvas-state.js';
4
+ import { findCanvasExtAppNodeId } from './ext-app-lookup.js';
4
5
  import { onFileNodeChanged } from './file-watcher.js';
5
6
  import { findOpenCanvasPosition, computeGroupBounds } from './placement.js';
6
7
  import { searchNodes, buildSpatialContext } from './spatial-analysis.js';
@@ -44,6 +45,8 @@ import {
44
45
  } from './mcp-app-runtime.js';
45
46
  import {
46
47
  buildExcalidrawOpenMcpAppInput,
48
+ ensureExcalidrawCheckpointId,
49
+ isExcalidrawCreateView,
47
50
  type DiagramPresetOpenInput,
48
51
  } from './diagram-presets.js';
49
52
  import {
@@ -323,16 +326,22 @@ export class PmxCanvas extends EventEmitter {
323
326
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
324
327
  }
325
328
 
326
- focusNode(id: string): void {
329
+ focusNode(id: string, options?: { noPan?: boolean }): { focused: string; panned: boolean } | null {
327
330
  const node = canvasState.getNode(id);
328
- if (!node) return;
329
- canvasState.setViewport({
330
- x: node.position.x - 100,
331
- y: node.position.y - 100,
332
- });
333
- emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId: id });
334
- emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
331
+ if (!node) return null;
332
+ const noPan = options?.noPan === true;
333
+ if (!noPan) {
334
+ canvasState.setViewport({
335
+ x: node.position.x - 100,
336
+ y: node.position.y - 100,
337
+ });
338
+ }
339
+ emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId: id, noPan });
340
+ if (!noPan) {
341
+ emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
342
+ }
335
343
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
344
+ return { focused: id, panned: !noPan };
336
345
  }
337
346
 
338
347
  getLayout(): CanvasLayout {
@@ -432,18 +441,10 @@ export class PmxCanvas extends EventEmitter {
432
441
  }
433
442
 
434
443
  private findCanvasExtAppNodeId(toolCallId: string): string | null {
435
- const directId = `ext-app-${toolCallId}`;
436
- if (canvasState.getNode(directId)) return directId;
437
- for (const node of canvasState.getLayout().nodes) {
438
- if (
439
- node.type === 'mcp-app' &&
440
- node.data.mode === 'ext-app' &&
441
- node.data.toolCallId === toolCallId
442
- ) {
443
- return node.id;
444
- }
445
- }
446
- return null;
444
+ return findCanvasExtAppNodeId(toolCallId, {
445
+ getNode: (id) => canvasState.getNode(id),
446
+ listNodes: () => canvasState.getLayout().nodes,
447
+ });
447
448
  }
448
449
 
449
450
  describeSchema() {
@@ -480,16 +481,21 @@ export class PmxCanvas extends EventEmitter {
480
481
  y?: number;
481
482
  width?: number;
482
483
  height?: number;
483
- }): Promise<{ ok: true; nodeId: string | null; toolCallId: string; sessionId: string; resourceUri: string }> {
484
+ }): Promise<{ ok: true; id?: string; nodeId: string | null; toolCallId: string; sessionId: string; resourceUri: string }> {
484
485
  const opened = await openExternalMcpApp({
485
486
  transport: input.transport,
486
487
  toolName: input.toolName,
487
488
  ...(input.toolArguments ? { toolArguments: input.toolArguments } : {}),
488
489
  ...(input.serverName ? { serverName: input.serverName } : {}),
489
490
  });
490
- const toolCallId = `ext-app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
491
+ const toolCallId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
492
+ const nodeIdSeed = `ext-app-${toolCallId}`;
493
+ const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
494
+ ? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
495
+ : opened.toolResult;
491
496
  emitPrimaryWorkbenchEvent('ext-app-open', {
492
497
  toolCallId,
498
+ nodeId: nodeIdSeed,
493
499
  title: input.title ?? opened.tool.title ?? opened.tool.name,
494
500
  html: opened.html,
495
501
  toolInput: opened.toolInput,
@@ -509,14 +515,16 @@ export class PmxCanvas extends EventEmitter {
509
515
  });
510
516
  emitPrimaryWorkbenchEvent('ext-app-result', {
511
517
  toolCallId,
518
+ nodeId: nodeIdSeed,
512
519
  serverName: opened.serverName,
513
520
  toolName: opened.toolName,
514
- success: opened.toolResult.isError !== true,
515
- result: opened.toolResult,
521
+ success: toolResult.isError !== true,
522
+ result: toolResult,
516
523
  });
517
524
  const nodeId = this.findCanvasExtAppNodeId(toolCallId);
518
525
  return {
519
526
  ok: true,
527
+ ...(nodeId ? { id: nodeId } : {}),
520
528
  nodeId,
521
529
  toolCallId,
522
530
  sessionId: opened.sessionId,
@@ -526,7 +534,7 @@ export class PmxCanvas extends EventEmitter {
526
534
 
527
535
  async addDiagram(
528
536
  input: DiagramPresetOpenInput,
529
- ): Promise<{ ok: true; nodeId: string | null; toolCallId: string; sessionId: string; resourceUri: string }> {
537
+ ): Promise<{ ok: true; id?: string; nodeId: string | null; toolCallId: string; sessionId: string; resourceUri: string }> {
530
538
  const built = buildExcalidrawOpenMcpAppInput(input);
531
539
  return this.openMcpApp(built);
532
540
  }