pmx-canvas 0.1.13 → 0.1.15

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 (58) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/Readme.md +108 -1058
  3. package/dist/canvas/global.css +141 -0
  4. package/dist/canvas/index.js +137 -87
  5. package/dist/json-render/index.css +1 -1
  6. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -3
  7. package/dist/types/client/nodes/HtmlNode.d.ts +5 -0
  8. package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
  9. package/dist/types/client/state/canvas-store.d.ts +5 -1
  10. package/dist/types/client/state/intent-bridge.d.ts +3 -1
  11. package/dist/types/client/types.d.ts +2 -2
  12. package/dist/types/json-render/catalog.d.ts +1 -1
  13. package/dist/types/mcp/canvas-access.d.ts +7 -1
  14. package/dist/types/server/agent-context.d.ts +1 -0
  15. package/dist/types/server/canvas-operations.d.ts +12 -2
  16. package/dist/types/server/canvas-provenance.d.ts +1 -1
  17. package/dist/types/server/canvas-serialization.d.ts +3 -0
  18. package/dist/types/server/canvas-state.d.ts +51 -4
  19. package/dist/types/server/demo.d.ts +5 -0
  20. package/dist/types/server/diagram-presets.d.ts +4 -0
  21. package/dist/types/server/index.d.ts +21 -3
  22. package/dist/types/server/mcp-app-runtime.d.ts +1 -0
  23. package/dist/types/server/web-artifacts.d.ts +18 -0
  24. package/dist/types/shared/canvas-node-kind.d.ts +5 -0
  25. package/package.json +1 -1
  26. package/skills/pmx-canvas/SKILL.md +43 -0
  27. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  28. package/src/cli/agent.ts +66 -5
  29. package/src/cli/index.ts +2 -23
  30. package/src/client/canvas/AttentionHistory.tsx +14 -1
  31. package/src/client/canvas/CanvasNode.tsx +1 -1
  32. package/src/client/canvas/CanvasViewport.tsx +3 -0
  33. package/src/client/canvas/DockedNode.tsx +110 -12
  34. package/src/client/canvas/ExpandedNodeOverlay.tsx +8 -3
  35. package/src/client/canvas/Minimap.tsx +1 -0
  36. package/src/client/icons.tsx +1 -0
  37. package/src/client/nodes/ExtAppFrame.tsx +10 -35
  38. package/src/client/nodes/HtmlNode.tsx +151 -0
  39. package/src/client/nodes/McpAppNode.tsx +2 -2
  40. package/src/client/state/canvas-store.ts +24 -2
  41. package/src/client/state/intent-bridge.ts +4 -3
  42. package/src/client/state/sse-bridge.ts +2 -0
  43. package/src/client/theme/global.css +141 -0
  44. package/src/client/types.ts +3 -0
  45. package/src/mcp/canvas-access.ts +34 -7
  46. package/src/mcp/server.ts +199 -26
  47. package/src/server/agent-context.ts +50 -3
  48. package/src/server/canvas-operations.ts +55 -3
  49. package/src/server/canvas-provenance.ts +2 -1
  50. package/src/server/canvas-serialization.ts +38 -13
  51. package/src/server/canvas-state.ts +305 -34
  52. package/src/server/demo.ts +792 -0
  53. package/src/server/diagram-presets.ts +45 -25
  54. package/src/server/index.ts +64 -7
  55. package/src/server/mcp-app-runtime.ts +15 -5
  56. package/src/server/server.ts +169 -63
  57. package/src/server/web-artifacts.ts +116 -3
  58. package/src/shared/canvas-node-kind.ts +14 -0
@@ -43,11 +43,13 @@ export const EXCALIDRAW_MCP_TRANSPORT: ExternalMcpTransportConfig = {
43
43
 
44
44
  export interface DiagramPresetOpenInput {
45
45
  elements: unknown;
46
+ nodeId?: string;
46
47
  title?: string;
47
48
  x?: number;
48
49
  y?: number;
49
50
  width?: number;
50
51
  height?: number;
52
+ timeoutMs?: number;
51
53
  }
52
54
 
53
55
  export interface ExcalidrawOpenMcpAppInput {
@@ -55,11 +57,13 @@ export interface ExcalidrawOpenMcpAppInput {
55
57
  toolName: string;
56
58
  serverName: string;
57
59
  toolArguments: { elements: string };
60
+ nodeId?: string;
58
61
  title?: string;
59
62
  x?: number;
60
63
  y?: number;
61
64
  width?: number;
62
65
  height?: number;
66
+ timeoutMs?: number;
63
67
  }
64
68
 
65
69
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -111,6 +115,21 @@ function finiteNumber(value: unknown): number | null {
111
115
  return typeof value === 'number' && Number.isFinite(value) ? value : null;
112
116
  }
113
117
 
118
+ function positiveFiniteNumber(value: unknown): number | null {
119
+ const num = finiteNumber(value);
120
+ return num !== null && num > 0 ? num : null;
121
+ }
122
+
123
+ function labelFromBoundText(element: Record<string, unknown>): Record<string, unknown> | null {
124
+ const text = typeof element.text === 'string' ? element.text : '';
125
+ if (text.trim().length === 0) return null;
126
+ const fontSize = positiveFiniteNumber(element.fontSize);
127
+ return {
128
+ text,
129
+ ...(fontSize ? { fontSize } : {}),
130
+ };
131
+ }
132
+
114
133
  function elementHasCameraUpdate(elements: Array<Record<string, unknown>>): boolean {
115
134
  return elements.some((element) => element.type === 'cameraUpdate');
116
135
  }
@@ -134,39 +153,38 @@ function normalizeExcalidrawBoundText(elements: Array<Record<string, unknown>>):
134
153
  }
135
154
 
136
155
  let changed = false;
137
- const boundElementIdsByContainer = new Map<string, Set<string>>();
156
+ const labelsByContainer = new Map<string, Record<string, unknown>>();
138
157
 
139
158
  for (const element of elements) {
140
159
  if (element.type !== 'text' || typeof element.id !== 'string' || typeof element.containerId !== 'string') continue;
141
160
  const container = elementsById.get(element.containerId);
142
- if (!container) continue;
143
- const ids = boundElementIdsByContainer.get(element.containerId) ?? new Set<string>();
144
- ids.add(element.id);
145
- boundElementIdsByContainer.set(element.containerId, ids);
161
+ if (!container || (container.type !== 'rectangle' && container.type !== 'ellipse' && container.type !== 'diamond')) continue;
162
+ const label = labelFromBoundText(element);
163
+ if (!label) continue;
164
+ labelsByContainer.set(element.containerId, label);
146
165
  }
147
166
 
148
- const normalized = elements.map((element) => {
149
- if (typeof element.id !== 'string') return element;
150
- const boundTextIds = boundElementIdsByContainer.get(element.id);
151
- if (!boundTextIds || boundTextIds.size === 0) return element;
152
-
153
- const existing = Array.isArray(element.boundElements)
154
- ? element.boundElements.filter(isRecord)
155
- : [];
156
- const existingTextIds = new Set(
157
- existing
158
- .filter((boundElement) => boundElement.type === 'text' && typeof boundElement.id === 'string')
159
- .map((boundElement) => boundElement.id as string),
160
- );
161
- const missing = [...boundTextIds].filter((id) => !existingTextIds.has(id));
162
- if (missing.length === 0) return element;
167
+ const normalized: Array<Record<string, unknown>> = [];
168
+ for (const element of elements) {
169
+ if (element.type === 'text' && typeof element.containerId === 'string') {
170
+ if (labelsByContainer.has(element.containerId)) {
171
+ changed = true;
172
+ continue;
173
+ }
174
+ }
175
+
176
+ if (typeof element.id !== 'string' || !labelsByContainer.has(element.id)) {
177
+ normalized.push(element);
178
+ continue;
179
+ }
163
180
 
164
181
  changed = true;
165
- return {
166
- ...element,
167
- boundElements: [...existing, ...missing.map((id) => ({ type: 'text', id }))],
168
- };
169
- });
182
+ const { boundElements: _boundElements, ...container } = element;
183
+ normalized.push({
184
+ ...container,
185
+ label: labelsByContainer.get(element.id),
186
+ });
187
+ }
170
188
 
171
189
  return changed ? normalized : elements;
172
190
  }
@@ -333,10 +351,12 @@ export function buildExcalidrawOpenMcpAppInput(input: DiagramPresetOpenInput): E
333
351
  serverName: EXCALIDRAW_SERVER_NAME,
334
352
  toolArguments: { elements },
335
353
  };
354
+ if (typeof input.nodeId === 'string' && input.nodeId.trim().length > 0) out.nodeId = input.nodeId.trim();
336
355
  if (typeof input.title === 'string' && input.title.trim().length > 0) out.title = input.title.trim();
337
356
  if (typeof input.x === 'number' && Number.isFinite(input.x)) out.x = input.x;
338
357
  if (typeof input.y === 'number' && Number.isFinite(input.y)) out.y = input.y;
339
358
  if (typeof input.width === 'number' && Number.isFinite(input.width)) out.width = input.width;
340
359
  if (typeof input.height === 'number' && Number.isFinite(input.height)) out.height = input.height;
360
+ if (typeof input.timeoutMs === 'number' && Number.isFinite(input.timeoutMs) && input.timeoutMs > 0) out.timeoutMs = input.timeoutMs;
341
361
  return out;
342
362
  }
@@ -20,6 +20,7 @@ import {
20
20
  fitCanvasView,
21
21
  deleteCanvasSnapshot,
22
22
  executeCanvasBatch,
23
+ gcCanvasSnapshots,
23
24
  groupCanvasNodes,
24
25
  listCanvasSnapshots,
25
26
  refreshCanvasWebpageNode,
@@ -33,6 +34,8 @@ import {
33
34
  ungroupCanvasNodes,
34
35
  validateCanvasNodePatch,
35
36
  hasStructuredNodeUpdateFields,
37
+ hasTraceNodeDataFields,
38
+ mergeTraceNodeDataFields,
36
39
  } from './canvas-operations.js';
37
40
  import { validateCanvasLayout } from './canvas-validation.js';
38
41
  import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
@@ -158,6 +161,12 @@ export class PmxCanvas extends EventEmitter {
158
161
  type: CanvasNodeState['type'];
159
162
  title?: string;
160
163
  content?: string;
164
+ toolName?: string;
165
+ category?: string;
166
+ status?: string;
167
+ duration?: string;
168
+ resultSummary?: string;
169
+ error?: string;
161
170
  x?: number;
162
171
  y?: number;
163
172
  width?: number;
@@ -243,9 +252,10 @@ export class PmxCanvas extends EventEmitter {
243
252
  patch.title !== undefined ||
244
253
  patch.content !== undefined ||
245
254
  typeof patch.arrangeLocked === 'boolean' ||
246
- typeof patch.strictSize === 'boolean'
255
+ typeof patch.strictSize === 'boolean' ||
256
+ (existing.type === 'trace' && hasTraceNodeDataFields(patch))
247
257
  ) {
248
- resolvedPatch.data = {
258
+ const nextData = {
249
259
  ...existing.data,
250
260
  ...(patch.data && typeof patch.data === 'object' && !Array.isArray(patch.data) ? patch.data : {}),
251
261
  ...(typeof patch.title === 'string' ? { title: patch.title } : {}),
@@ -253,6 +263,9 @@ export class PmxCanvas extends EventEmitter {
253
263
  ...(typeof patch.arrangeLocked === 'boolean' ? { arrangeLocked: patch.arrangeLocked } : {}),
254
264
  ...(typeof patch.strictSize === 'boolean' ? { strictSize: patch.strictSize } : {}),
255
265
  };
266
+ resolvedPatch.data = existing.type === 'trace'
267
+ ? mergeTraceNodeDataFields(nextData, patch)
268
+ : nextData;
256
269
  }
257
270
 
258
271
  const error = validateCanvasNodePatch({
@@ -447,8 +460,8 @@ export class PmxCanvas extends EventEmitter {
447
460
  return result;
448
461
  }
449
462
 
450
- listSnapshots() {
451
- return listCanvasSnapshots();
463
+ listSnapshots(options?: Parameters<typeof listCanvasSnapshots>[0]) {
464
+ return listCanvasSnapshots(options);
452
465
  }
453
466
 
454
467
  saveSnapshot(name: string) {
@@ -467,6 +480,10 @@ export class PmxCanvas extends EventEmitter {
467
480
  return deleteCanvasSnapshot(id);
468
481
  }
469
482
 
483
+ gcSnapshots(options?: Parameters<typeof gcCanvasSnapshots>[0]): ReturnType<typeof gcCanvasSnapshots> {
484
+ return gcCanvasSnapshots(options);
485
+ }
486
+
470
487
  diffSnapshot(idOrName: string): { ok: boolean; text?: string; diff?: ReturnType<typeof diffLayouts>; error?: string } {
471
488
  const snapData = canvasState.getSnapshotData(idOrName);
472
489
  if (!snapData) return { ok: false, error: `Snapshot "${idOrName}" not found` };
@@ -520,21 +537,36 @@ export class PmxCanvas extends EventEmitter {
520
537
  transport: ExternalMcpTransportConfig;
521
538
  toolName: string;
522
539
  toolArguments?: Record<string, unknown>;
540
+ nodeId?: string;
523
541
  serverName?: string;
524
542
  title?: string;
525
543
  x?: number;
526
544
  y?: number;
527
545
  width?: number;
528
546
  height?: number;
547
+ timeoutMs?: number;
529
548
  }): Promise<{ ok: true; id?: string; nodeId: string | null; toolCallId: string; sessionId: string; resourceUri: string }> {
549
+ const targetNode = input.nodeId ? canvasState.getNode(input.nodeId) : undefined;
550
+ if (input.nodeId && !targetNode) {
551
+ throw new Error(`Node "${input.nodeId}" not found.`);
552
+ }
553
+ if (targetNode && (targetNode.type !== 'mcp-app' || targetNode.data.mode !== 'ext-app')) {
554
+ throw new Error(`Node "${input.nodeId}" is not an external app node.`);
555
+ }
556
+
530
557
  const opened = await openExternalMcpApp({
531
558
  transport: input.transport,
532
559
  toolName: input.toolName,
533
560
  ...(input.toolArguments ? { toolArguments: input.toolArguments } : {}),
534
561
  ...(input.serverName ? { serverName: input.serverName } : {}),
562
+ ...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
535
563
  });
536
564
  const toolCallId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
537
- const nodeIdSeed = `ext-app-${toolCallId}`;
565
+ const previousSessionId = targetNode?.data.appSessionId;
566
+ if (typeof previousSessionId === 'string' && previousSessionId.trim().length > 0) {
567
+ closeMcpAppSession(previousSessionId);
568
+ }
569
+ const nodeIdSeed = input.nodeId ?? `ext-app-${toolCallId}`;
538
570
  const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
539
571
  ? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
540
572
  : opened.toolResult;
@@ -566,7 +598,7 @@ export class PmxCanvas extends EventEmitter {
566
598
  success: toolResult.isError !== true,
567
599
  result: toolResult,
568
600
  });
569
- const nodeId = this.findCanvasExtAppNodeId(toolCallId);
601
+ const nodeId = input.nodeId ?? this.findCanvasExtAppNodeId(toolCallId);
570
602
  return {
571
603
  ok: true,
572
604
  ...(nodeId ? { id: nodeId } : {}),
@@ -592,6 +624,31 @@ export class PmxCanvas extends EventEmitter {
592
624
  return result;
593
625
  }
594
626
 
627
+ addHtmlNode(input: {
628
+ html: string;
629
+ title?: string;
630
+ x?: number;
631
+ y?: number;
632
+ width?: number;
633
+ height?: number;
634
+ strictSize?: boolean;
635
+ }): string {
636
+ const { id } = addCanvasNode({
637
+ type: 'html',
638
+ ...(typeof input.title === 'string' ? { title: input.title } : {}),
639
+ data: { html: input.html },
640
+ ...(typeof input.x === 'number' ? { x: input.x } : {}),
641
+ ...(typeof input.y === 'number' ? { y: input.y } : {}),
642
+ ...(typeof input.width === 'number' ? { width: input.width } : {}),
643
+ ...(typeof input.height === 'number' ? { height: input.height } : {}),
644
+ ...(input.strictSize ? { strictSize: true } : {}),
645
+ defaultWidth: 720,
646
+ defaultHeight: 640,
647
+ });
648
+ emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
649
+ return id;
650
+ }
651
+
595
652
  addGraphNode(input: GraphNodeInput): { id: string; url: string; spec: JsonRenderSpec } {
596
653
  const result = createCanvasGraphNode(input);
597
654
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
@@ -666,7 +723,7 @@ export {
666
723
  screenshotCanvasAutomationWebView,
667
724
  } from './server.js';
668
725
  export { canvasState } from './canvas-state.js';
669
- export type { CanvasSnapshot } from './canvas-state.js';
726
+ export type { CanvasSnapshot, CanvasSnapshotGcResult, CanvasSnapshotListOptions } from './canvas-state.js';
670
727
  export { findOpenCanvasPosition } from './placement.js';
671
728
  export { searchNodes, buildSpatialContext, detectClusters, findNeighborhoods } from './spatial-analysis.js';
672
729
  export type { SpatialCluster, SpatialContext, SpatialNeighbor, NodeSpatialInfo } from './spatial-analysis.js';
@@ -12,6 +12,7 @@ import type {
12
12
  TextResourceContents,
13
13
  Tool,
14
14
  } from '@modelcontextprotocol/sdk/types.js';
15
+ import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
15
16
  import {
16
17
  EXTENSION_ID,
17
18
  RESOURCE_MIME_TYPE,
@@ -47,6 +48,7 @@ export interface OpenMcpAppInput {
47
48
  toolName: string;
48
49
  toolArguments?: Record<string, unknown>;
49
50
  serverName?: string;
51
+ timeoutMs?: number;
50
52
  }
51
53
 
52
54
  export interface OpenMcpAppResult {
@@ -184,6 +186,12 @@ function normalizeServerName(raw: string | undefined, transport: ExternalMcpTran
184
186
  return trimmed.length > 0 ? trimmed : defaultServerName(transport);
185
187
  }
186
188
 
189
+ function requestOptions(timeoutMs: number | undefined): RequestOptions | undefined {
190
+ return typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0
191
+ ? { timeout: timeoutMs }
192
+ : undefined;
193
+ }
194
+
187
195
  function buildTransport(config: ExternalMcpTransportConfig): RuntimeTransport {
188
196
  if (config.type === 'http') {
189
197
  return new StreamableHTTPClientTransport(new URL(config.url), {
@@ -209,15 +217,16 @@ function buildTransport(config: ExternalMcpTransportConfig): RuntimeTransport {
209
217
  async function createSession(
210
218
  transportConfig: ExternalMcpTransportConfig,
211
219
  serverName?: string,
220
+ timeoutMs?: number,
212
221
  ): Promise<McpAppSession> {
213
222
  const transport = buildTransport(transportConfig);
214
223
  const client = new Client(
215
224
  { name: 'pmx-canvas-app-host', version: '0.1.0' },
216
225
  { capabilities: clientCapabilities },
217
226
  );
218
- await client.connect(transport);
227
+ await client.connect(transport, requestOptions(timeoutMs));
219
228
 
220
- const toolList = await client.listTools();
229
+ const toolList = await client.listTools(undefined, requestOptions(timeoutMs));
221
230
  const session: McpAppSession = {
222
231
  id: randomId('mcp-app-session'),
223
232
  serverName: normalizeServerName(serverName, transportConfig),
@@ -350,7 +359,8 @@ function prepareResourceHtml(html: string, meta: McpUiResourceMeta | undefined):
350
359
  }
351
360
 
352
361
  export async function openMcpApp(input: OpenMcpAppInput): Promise<OpenMcpAppResult> {
353
- const session = await createSession(input.transport, input.serverName);
362
+ const options = requestOptions(input.timeoutMs);
363
+ const session = await createSession(input.transport, input.serverName, input.timeoutMs);
354
364
  try {
355
365
  const tool = await findTool(session, input.toolName);
356
366
  const resourceUri = getToolUiResourceUri(tool);
@@ -362,9 +372,9 @@ export async function openMcpApp(input: OpenMcpAppInput): Promise<OpenMcpAppResu
362
372
  const rawToolResult = await session.client.callTool({
363
373
  name: tool.name,
364
374
  arguments: toolInput,
365
- });
375
+ }, undefined, options);
366
376
  const toolResult = normalizeExtAppToolResult({ result: rawToolResult });
367
- const readResult = await session.client.readResource({ uri: resourceUri });
377
+ const readResult = await session.client.readResource({ uri: resourceUri }, options);
368
378
  const resourceMeta = resourceMetaFromReadResult(readResult);
369
379
  const html = prepareResourceHtml(htmlContentFromReadResult(readResult, resourceUri), resourceMeta);
370
380