pmx-canvas 0.1.2 → 0.1.3
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 +62 -0
- package/Readme.md +35 -8
- package/dist/canvas/index.js +69 -69
- package/dist/types/client/nodes/ExtAppFrame.d.ts +12 -0
- package/dist/types/client/state/canvas-store.d.ts +2 -1
- package/dist/types/client/types.d.ts +3 -0
- package/dist/types/server/diagram-presets.d.ts +13 -0
- package/dist/types/server/index.d.ts +6 -1
- package/dist/types/server/web-artifacts.d.ts +1 -0
- package/package.json +2 -1
- package/skills/pmx-canvas/SKILL.md +26 -5
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +66 -0
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +10 -0
- package/skills/web-artifacts-builder/scripts/init-artifact.sh +1 -1
- package/src/cli/agent.ts +78 -7
- package/src/cli/index.ts +3 -1
- package/src/client/App.tsx +2 -1
- package/src/client/canvas/CanvasNode.tsx +3 -2
- package/src/client/canvas/ExpandedNodeOverlay.tsx +6 -1
- package/src/client/nodes/ExtAppFrame.tsx +97 -26
- package/src/client/state/canvas-store.ts +63 -1
- package/src/client/state/sse-bridge.ts +5 -0
- package/src/client/types.ts +12 -0
- package/src/mcp/server.ts +28 -5
- package/src/server/canvas-operations.ts +35 -5
- package/src/server/canvas-schema.ts +2 -1
- package/src/server/diagram-presets.ts +219 -4
- package/src/server/index.ts +22 -10
- package/src/server/server.ts +152 -32
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +10 -0
- package/src/server/web-artifacts/scripts/init-artifact.sh +1 -1
- package/src/server/web-artifacts.ts +43 -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
|
-
|
|
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
|
|
85
|
+
return parsed.filter(isRecord);
|
|
50
86
|
}
|
|
87
|
+
|
|
51
88
|
if (Array.isArray(elements)) {
|
|
52
|
-
return
|
|
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 =
|
|
273
|
+
const elements = normalizeExcalidrawElementsForToolInput(input.elements);
|
|
59
274
|
const out: ExcalidrawOpenMcpAppInput = {
|
|
60
275
|
transport: EXCALIDRAW_MCP_TRANSPORT,
|
|
61
276
|
toolName: EXCALIDRAW_CREATE_VIEW_TOOL,
|
package/src/server/index.ts
CHANGED
|
@@ -44,6 +44,8 @@ import {
|
|
|
44
44
|
} from './mcp-app-runtime.js';
|
|
45
45
|
import {
|
|
46
46
|
buildExcalidrawOpenMcpAppInput,
|
|
47
|
+
ensureExcalidrawCheckpointId,
|
|
48
|
+
isExcalidrawCreateView,
|
|
47
49
|
type DiagramPresetOpenInput,
|
|
48
50
|
} from './diagram-presets.js';
|
|
49
51
|
import {
|
|
@@ -323,16 +325,22 @@ export class PmxCanvas extends EventEmitter {
|
|
|
323
325
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
324
326
|
}
|
|
325
327
|
|
|
326
|
-
focusNode(id: string):
|
|
328
|
+
focusNode(id: string, options?: { noPan?: boolean }): { focused: string; panned: boolean } | null {
|
|
327
329
|
const node = canvasState.getNode(id);
|
|
328
|
-
if (!node) return;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
330
|
+
if (!node) return null;
|
|
331
|
+
const noPan = options?.noPan === true;
|
|
332
|
+
if (!noPan) {
|
|
333
|
+
canvasState.setViewport({
|
|
334
|
+
x: node.position.x - 100,
|
|
335
|
+
y: node.position.y - 100,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId: id, noPan });
|
|
339
|
+
if (!noPan) {
|
|
340
|
+
emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
|
|
341
|
+
}
|
|
335
342
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
343
|
+
return { focused: id, panned: !noPan };
|
|
336
344
|
}
|
|
337
345
|
|
|
338
346
|
getLayout(): CanvasLayout {
|
|
@@ -488,6 +496,10 @@ export class PmxCanvas extends EventEmitter {
|
|
|
488
496
|
...(input.serverName ? { serverName: input.serverName } : {}),
|
|
489
497
|
});
|
|
490
498
|
const toolCallId = `ext-app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
499
|
+
const nodeIdSeed = `ext-app-${toolCallId}`;
|
|
500
|
+
const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
|
|
501
|
+
? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
|
|
502
|
+
: opened.toolResult;
|
|
491
503
|
emitPrimaryWorkbenchEvent('ext-app-open', {
|
|
492
504
|
toolCallId,
|
|
493
505
|
title: input.title ?? opened.tool.title ?? opened.tool.name,
|
|
@@ -511,8 +523,8 @@ export class PmxCanvas extends EventEmitter {
|
|
|
511
523
|
toolCallId,
|
|
512
524
|
serverName: opened.serverName,
|
|
513
525
|
toolName: opened.toolName,
|
|
514
|
-
success:
|
|
515
|
-
result:
|
|
526
|
+
success: toolResult.isError !== true,
|
|
527
|
+
result: toolResult,
|
|
516
528
|
});
|
|
517
529
|
const nodeId = this.findCanvasExtAppNodeId(toolCallId);
|
|
518
530
|
return {
|
package/src/server/server.ts
CHANGED
|
@@ -39,6 +39,7 @@ import { existsSync, readFileSync, statSync, writeFileSync, appendFileSync } fro
|
|
|
39
39
|
import { basename, extname, join, relative, resolve } from 'node:path';
|
|
40
40
|
import * as marked from 'marked';
|
|
41
41
|
import type {
|
|
42
|
+
CallToolResult,
|
|
42
43
|
ListPromptsResult,
|
|
43
44
|
ListResourcesResult,
|
|
44
45
|
ListResourceTemplatesResult,
|
|
@@ -92,7 +93,16 @@ import {
|
|
|
92
93
|
} from './canvas-operations.js';
|
|
93
94
|
import { validateCanvasLayout } from './canvas-validation.js';
|
|
94
95
|
import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
|
|
95
|
-
import {
|
|
96
|
+
import {
|
|
97
|
+
EXCALIDRAW_READ_CHECKPOINT_TOOL,
|
|
98
|
+
EXCALIDRAW_SAVE_CHECKPOINT_TOOL,
|
|
99
|
+
buildExcalidrawCheckpointId,
|
|
100
|
+
buildExcalidrawOpenMcpAppInput,
|
|
101
|
+
buildExcalidrawRestoreCheckpointToolInput,
|
|
102
|
+
ensureExcalidrawCheckpointId,
|
|
103
|
+
getExcalidrawCheckpointIdFromToolResult,
|
|
104
|
+
isExcalidrawCreateView,
|
|
105
|
+
} from './diagram-presets.js';
|
|
96
106
|
import { traceManager } from './trace-manager.js';
|
|
97
107
|
import { buildWebArtifactOnCanvas, resolveWorkspacePath } from './web-artifacts.js';
|
|
98
108
|
import {
|
|
@@ -606,6 +616,79 @@ function findCanvasExtAppNodeId(toolCallId: string): string | null {
|
|
|
606
616
|
return null;
|
|
607
617
|
}
|
|
608
618
|
|
|
619
|
+
function isCheckpointToolName(toolName: string): boolean {
|
|
620
|
+
return toolName === EXCALIDRAW_SAVE_CHECKPOINT_TOOL || toolName === EXCALIDRAW_READ_CHECKPOINT_TOOL;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function shouldReplayAppToolResult(toolName: string, result: CallToolResult): boolean {
|
|
624
|
+
void toolName;
|
|
625
|
+
return Boolean(
|
|
626
|
+
result.isError ||
|
|
627
|
+
result.structuredContent ||
|
|
628
|
+
result.content.some((entry) => entry.type !== 'text' || entry.text !== 'ok'),
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
633
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function getExtAppNodeCheckpointId(node: CanvasNodeState): string {
|
|
637
|
+
const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
|
|
638
|
+
const storedCheckpointId = appCheckpoint?.id;
|
|
639
|
+
if (typeof storedCheckpointId === 'string' && storedCheckpointId.trim().length > 0) {
|
|
640
|
+
return storedCheckpointId.trim();
|
|
641
|
+
}
|
|
642
|
+
return getExcalidrawCheckpointIdFromToolResult(node.data.toolResult) ?? buildExcalidrawCheckpointId(node.id);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function getLocalExcalidrawCheckpointData(
|
|
646
|
+
node: CanvasNodeState,
|
|
647
|
+
args: Record<string, unknown> | undefined,
|
|
648
|
+
): string | null {
|
|
649
|
+
if (!isExcalidrawCreateView(node.data.serverName, node.data.toolName)) return null;
|
|
650
|
+
if (!isRecord(args) || typeof args.id !== 'string') return null;
|
|
651
|
+
if (args.id.trim() !== getExtAppNodeCheckpointId(node)) return null;
|
|
652
|
+
const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
|
|
653
|
+
const data = appCheckpoint?.data;
|
|
654
|
+
return typeof data === 'string' ? data : '';
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function persistExcalidrawCheckpointToNode(
|
|
658
|
+
nodeId: string,
|
|
659
|
+
node: CanvasNodeState,
|
|
660
|
+
args: Record<string, unknown> | undefined,
|
|
661
|
+
): boolean {
|
|
662
|
+
if (!isExcalidrawCreateView(node.data.serverName, node.data.toolName)) return false;
|
|
663
|
+
if (!isRecord(args) || typeof args.id !== 'string') return false;
|
|
664
|
+
const checkpointId = getExtAppNodeCheckpointId(node);
|
|
665
|
+
if (args.id.trim() !== checkpointId) return false;
|
|
666
|
+
|
|
667
|
+
const currentToolInput = isRecord(node.data.toolInput) ? node.data.toolInput : {};
|
|
668
|
+
const nextToolInput = {
|
|
669
|
+
...currentToolInput,
|
|
670
|
+
elements: buildExcalidrawRestoreCheckpointToolInput(checkpointId, args.data),
|
|
671
|
+
};
|
|
672
|
+
const currentToolResult = isRecord(node.data.toolResult)
|
|
673
|
+
? ensureExcalidrawCheckpointId(node.data.toolResult as CallToolResult, node.id, checkpointId)
|
|
674
|
+
: undefined;
|
|
675
|
+
|
|
676
|
+
canvasState.updateNode(nodeId, {
|
|
677
|
+
data: {
|
|
678
|
+
...node.data,
|
|
679
|
+
toolInput: nextToolInput,
|
|
680
|
+
...(currentToolResult ? { toolResult: currentToolResult } : {}),
|
|
681
|
+
appCheckpoint: {
|
|
682
|
+
toolName: EXCALIDRAW_SAVE_CHECKPOINT_TOOL,
|
|
683
|
+
id: checkpointId,
|
|
684
|
+
...(typeof args.data === 'string' ? { data: args.data } : {}),
|
|
685
|
+
updatedAt: new Date().toISOString(),
|
|
686
|
+
},
|
|
687
|
+
},
|
|
688
|
+
});
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
|
|
609
692
|
function findReusableCanvasExtAppNodeId(serverName: string, toolName: string): string | null {
|
|
610
693
|
for (const node of canvasState.getLayout().nodes) {
|
|
611
694
|
if (
|
|
@@ -1323,7 +1406,13 @@ async function handleCanvasArrange(req: Request): Promise<Response> {
|
|
|
1323
1406
|
}
|
|
1324
1407
|
const result = arrangeCanvasNodes(layout as 'grid' | 'column' | 'flow');
|
|
1325
1408
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1326
|
-
|
|
1409
|
+
const validation = validateCanvasLayout(canvasState.getLayout());
|
|
1410
|
+
return responseJson({
|
|
1411
|
+
ok: validation.ok,
|
|
1412
|
+
arranged: result.arranged,
|
|
1413
|
+
layout: result.layout,
|
|
1414
|
+
...(validation.ok ? {} : { validation, collisions: validation.summary.collisions }),
|
|
1415
|
+
});
|
|
1327
1416
|
}
|
|
1328
1417
|
|
|
1329
1418
|
// ── Focus on node ───────────────────────────────────────────
|
|
@@ -1333,11 +1422,17 @@ async function handleCanvasFocus(req: Request): Promise<Response> {
|
|
|
1333
1422
|
if (!nodeId) return responseJson({ ok: false, error: 'Missing id.' }, 400);
|
|
1334
1423
|
const node = canvasState.getNode(nodeId);
|
|
1335
1424
|
if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1425
|
+
const noPan = body.noPan === true;
|
|
1426
|
+
if (!noPan) {
|
|
1427
|
+
canvasState.setViewport({ x: node.position.x - 100, y: node.position.y - 100 });
|
|
1428
|
+
} else {
|
|
1429
|
+
const maxZ = canvasState.getLayout().nodes.reduce((max, layoutNode) => Math.max(max, layoutNode.zIndex), 0);
|
|
1430
|
+
canvasState.updateNode(nodeId, { zIndex: maxZ + 1 });
|
|
1431
|
+
}
|
|
1432
|
+
emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId, noPan });
|
|
1433
|
+
if (!noPan) emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
|
|
1339
1434
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1340
|
-
return responseJson({ ok: true, focused: nodeId });
|
|
1435
|
+
return responseJson({ ok: true, focused: nodeId, panned: !noPan });
|
|
1341
1436
|
}
|
|
1342
1437
|
|
|
1343
1438
|
async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
|
|
@@ -1375,6 +1470,9 @@ async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
|
|
|
1375
1470
|
...(typeof body.bundleScriptPath === 'string'
|
|
1376
1471
|
? { bundleScriptPath: body.bundleScriptPath }
|
|
1377
1472
|
: {}),
|
|
1473
|
+
...(Array.isArray(body.deps)
|
|
1474
|
+
? { deps: body.deps.filter((dep): dep is string => typeof dep === 'string') }
|
|
1475
|
+
: {}),
|
|
1378
1476
|
...(typeof body.timeoutMs === 'number' ? { timeoutMs: body.timeoutMs } : {}),
|
|
1379
1477
|
...(typeof body.openInCanvas === 'boolean' ? { openInCanvas: body.openInCanvas } : {}),
|
|
1380
1478
|
});
|
|
@@ -1853,6 +1951,10 @@ async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise
|
|
|
1853
1951
|
});
|
|
1854
1952
|
|
|
1855
1953
|
const toolCallId = randomExtAppToolCallId();
|
|
1954
|
+
const nodeIdSeed = `ext-app-${toolCallId}`;
|
|
1955
|
+
const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
|
|
1956
|
+
? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
|
|
1957
|
+
: opened.toolResult;
|
|
1856
1958
|
const nodeTitle = params.title ?? opened.tool.title ?? opened.tool.name;
|
|
1857
1959
|
|
|
1858
1960
|
emitPrimaryWorkbenchEvent('ext-app-open', {
|
|
@@ -1878,8 +1980,8 @@ async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise
|
|
|
1878
1980
|
toolCallId,
|
|
1879
1981
|
serverName: opened.serverName,
|
|
1880
1982
|
toolName: opened.toolName,
|
|
1881
|
-
success:
|
|
1882
|
-
result:
|
|
1983
|
+
success: toolResult.isError !== true,
|
|
1984
|
+
result: toolResult,
|
|
1883
1985
|
});
|
|
1884
1986
|
const nodeId = findCanvasExtAppNodeId(toolCallId);
|
|
1885
1987
|
|
|
@@ -1979,35 +2081,53 @@ async function handleExtAppCallTool(req: Request): Promise<Response> {
|
|
|
1979
2081
|
const nodeId = typeof body.nodeId === 'string' ? body.nodeId.trim() : '';
|
|
1980
2082
|
|
|
1981
2083
|
try {
|
|
1982
|
-
const
|
|
2084
|
+
const requestedNode = nodeId ? canvasState.getNode(nodeId) : undefined;
|
|
2085
|
+
const canReadLocalCheckpoint =
|
|
2086
|
+
requestedNode?.type === 'mcp-app' &&
|
|
2087
|
+
requestedNode.data.mode === 'ext-app' &&
|
|
2088
|
+
requestedNode.data.appSessionId === sessionId;
|
|
2089
|
+
const localCheckpointData = canReadLocalCheckpoint && toolName === EXCALIDRAW_READ_CHECKPOINT_TOOL
|
|
2090
|
+
? getLocalExcalidrawCheckpointData(requestedNode, args)
|
|
2091
|
+
: null;
|
|
2092
|
+
const result = localCheckpointData === null
|
|
2093
|
+
? await callMcpAppTool(sessionId, toolName, args)
|
|
2094
|
+
: { content: [{ type: 'text', text: localCheckpointData }] } satisfies CallToolResult;
|
|
1983
2095
|
if (nodeId) {
|
|
1984
2096
|
const node = canvasState.getNode(nodeId);
|
|
1985
2097
|
if (node?.type === 'mcp-app' && node.data.mode === 'ext-app' && node.data.appSessionId === sessionId) {
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
if (
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
2098
|
+
let changed = false;
|
|
2099
|
+
if (toolName === EXCALIDRAW_SAVE_CHECKPOINT_TOOL && persistExcalidrawCheckpointToNode(nodeId, node, args)) {
|
|
2100
|
+
// Checkpoint saves are replayed through toolInput.elements instead of
|
|
2101
|
+
// replacing the original create_view result with a generic "ok".
|
|
2102
|
+
changed = true;
|
|
2103
|
+
} else if (!(isExcalidrawCreateView(node.data.serverName, node.data.toolName) && isCheckpointToolName(toolName))) {
|
|
2104
|
+
const nextData: Record<string, unknown> = { ...node.data };
|
|
2105
|
+
if (shouldReplayAppToolResult(toolName, result)) nextData.toolResult = result;
|
|
2106
|
+
const nextModelContext: Record<string, unknown> = {};
|
|
2107
|
+
if (Array.isArray(result.content)) {
|
|
2108
|
+
nextModelContext.content = result.content;
|
|
2109
|
+
}
|
|
2110
|
+
if (result.structuredContent && typeof result.structuredContent === 'object' && !Array.isArray(result.structuredContent)) {
|
|
2111
|
+
nextModelContext.structuredContent = result.structuredContent;
|
|
2112
|
+
}
|
|
2113
|
+
if (Object.keys(nextModelContext).length > 0) {
|
|
2114
|
+
nextData.appModelContext = {
|
|
2115
|
+
...nextModelContext,
|
|
2116
|
+
updatedAt: new Date().toISOString(),
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
canvasState.updateNode(nodeId, {
|
|
2120
|
+
data: nextData,
|
|
2121
|
+
});
|
|
2122
|
+
changed = true;
|
|
1996
2123
|
}
|
|
1997
|
-
if (
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2124
|
+
if (changed) {
|
|
2125
|
+
broadcastWorkbenchEvent('canvas-layout-update', {
|
|
2126
|
+
layout: canvasState.getLayout(),
|
|
2127
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2128
|
+
timestamp: new Date().toISOString(),
|
|
2129
|
+
});
|
|
2002
2130
|
}
|
|
2003
|
-
canvasState.updateNode(nodeId, {
|
|
2004
|
-
data: nextData,
|
|
2005
|
-
});
|
|
2006
|
-
broadcastWorkbenchEvent('canvas-layout-update', {
|
|
2007
|
-
layout: canvasState.getLayout(),
|
|
2008
|
-
sessionId: primaryWorkbenchSessionId,
|
|
2009
|
-
timestamp: new Date().toISOString(),
|
|
2010
|
-
});
|
|
2011
2131
|
}
|
|
2012
2132
|
}
|
|
2013
2133
|
return responseJson({ ok: true, result });
|
|
@@ -152,10 +152,20 @@ rm -rf dist bundle.html
|
|
|
152
152
|
echo "🔨 Building with Parcel..."
|
|
153
153
|
run_with_filtered_stderr run_local_binary parcel build index.html --dist-dir dist --no-source-maps --log-level error
|
|
154
154
|
|
|
155
|
+
if [ ! -s "dist/index.html" ]; then
|
|
156
|
+
echo "❌ Error: Parcel did not produce dist/index.html" >&2
|
|
157
|
+
exit 1
|
|
158
|
+
fi
|
|
159
|
+
|
|
155
160
|
# Inline everything into single HTML
|
|
156
161
|
echo "🎯 Inlining all assets into single HTML file..."
|
|
157
162
|
run_with_filtered_stderr run_local_binary html-inline dist/index.html > bundle.html
|
|
158
163
|
|
|
164
|
+
if [ ! -s "bundle.html" ]; then
|
|
165
|
+
echo "❌ Error: Bundled artifact is empty" >&2
|
|
166
|
+
exit 1
|
|
167
|
+
fi
|
|
168
|
+
|
|
159
169
|
# Get file size
|
|
160
170
|
FILE_SIZE=$(du -h bundle.html | cut -f1)
|
|
161
171
|
|
|
@@ -173,7 +173,7 @@ fi
|
|
|
173
173
|
|
|
174
174
|
echo "📦 Installing Tailwind CSS and dependencies..."
|
|
175
175
|
run_pnpm_allow_build add -D tailwindcss@3.4.1 postcss @types/node tailwindcss-animate parcel @parcel/config-default parcel-resolver-tspaths html-inline
|
|
176
|
-
run_pnpm_quiet add class-variance-authority clsx tailwind-merge lucide-react next-themes
|
|
176
|
+
run_pnpm_quiet add class-variance-authority clsx tailwind-merge lucide-react next-themes recharts
|
|
177
177
|
|
|
178
178
|
echo "⚙️ Creating Tailwind and PostCSS configuration..."
|
|
179
179
|
cat > .postcssrc.json << 'EOF'
|
|
@@ -38,6 +38,7 @@ export interface WebArtifactBuildInput {
|
|
|
38
38
|
outputPath?: string;
|
|
39
39
|
initScriptPath?: string;
|
|
40
40
|
bundleScriptPath?: string;
|
|
41
|
+
deps?: string[];
|
|
41
42
|
timeoutMs?: number;
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -322,6 +323,19 @@ function ensurePackageManagerBoundary(dirPath: string): void {
|
|
|
322
323
|
writeFileSync(packageJsonPath, JSON.stringify(nextPackageJson, null, 2), 'utf-8');
|
|
323
324
|
}
|
|
324
325
|
|
|
326
|
+
function normalizeDependencyNames(deps: string[] | undefined): string[] {
|
|
327
|
+
const normalized = new Set<string>();
|
|
328
|
+
for (const dep of deps ?? []) {
|
|
329
|
+
const trimmed = dep.trim();
|
|
330
|
+
if (!trimmed) continue;
|
|
331
|
+
if (trimmed.startsWith('-') || !/^(@[a-z0-9._-]+\/)?[a-z0-9._-]+(@[\w.+~^*-][\w.+~^*-]*)?$/i.test(trimmed)) {
|
|
332
|
+
throw new Error(`Invalid web-artifact dependency name: ${dep}`);
|
|
333
|
+
}
|
|
334
|
+
normalized.add(trimmed);
|
|
335
|
+
}
|
|
336
|
+
return [...normalized];
|
|
337
|
+
}
|
|
338
|
+
|
|
325
339
|
function summarizeArtifactLog(text: string): WebArtifactLogSummary | undefined {
|
|
326
340
|
if (!text.trim()) return undefined;
|
|
327
341
|
|
|
@@ -391,6 +405,23 @@ export async function executeWebArtifactBuild(
|
|
|
391
405
|
|
|
392
406
|
writeProjectFiles(projectPath, input);
|
|
393
407
|
|
|
408
|
+
const deps = normalizeDependencyNames(input.deps);
|
|
409
|
+
if (deps.length > 0) {
|
|
410
|
+
const quotedDeps = deps.map((dep) => `'${dep.replaceAll("'", "'\\''")}'`).join(' ');
|
|
411
|
+
const pnpmVersion = DEFAULT_PACKAGE_MANAGER.split('@')[1] ?? '10.33.0';
|
|
412
|
+
// Use `bash -c` (not `-lc`): `-lc` sources the user's login profile
|
|
413
|
+
// (~/.bashrc, ~/.zshrc), which can mutate PATH or set aliases that
|
|
414
|
+
// change install behavior. With deps already validated against
|
|
415
|
+
// npm-name format and quoted via single-quote escaping, the regular
|
|
416
|
+
// shell is sufficient and reproducible across machines.
|
|
417
|
+
const depResult = await runProcess('bash', ['-c', `if command -v pnpm >/dev/null 2>&1; then pnpm --silent add --ignore-scripts -- ${quotedDeps}; elif command -v bun >/dev/null 2>&1; then bun x pnpm@${pnpmVersion} --silent add --ignore-scripts -- ${quotedDeps}; else npm install --ignore-scripts -- ${quotedDeps}; fi`], {
|
|
418
|
+
cwd: projectPath,
|
|
419
|
+
timeoutMs,
|
|
420
|
+
});
|
|
421
|
+
stdout = [stdout, depResult.stdout].filter(Boolean).join('\n');
|
|
422
|
+
stderr = [stderr, depResult.stderr].filter(Boolean).join('\n');
|
|
423
|
+
}
|
|
424
|
+
|
|
394
425
|
const bundleResult = await runProcess('bash', [bundleScriptPath], {
|
|
395
426
|
cwd: projectPath,
|
|
396
427
|
timeoutMs,
|
|
@@ -403,18 +434,29 @@ export async function executeWebArtifactBuild(
|
|
|
403
434
|
throw new Error(`Expected bundled artifact at ${bundlePath}`);
|
|
404
435
|
}
|
|
405
436
|
|
|
437
|
+
const bundleSize = statSync(bundlePath).size;
|
|
438
|
+
if (bundleSize <= 0) {
|
|
439
|
+
throw new Error(`Bundled artifact is empty: ${bundlePath}`);
|
|
440
|
+
}
|
|
441
|
+
|
|
406
442
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
407
443
|
copyFileSync(bundlePath, outputPath);
|
|
444
|
+
// The script-side check in bundle-artifact.sh and the bundleSize check
|
|
445
|
+
// above already guarantee a non-empty source; copyFileSync would throw
|
|
446
|
+
// on a filesystem failure. A post-copy size check would be redundant
|
|
447
|
+
// defensive noise — see CLAUDE.md TypeScript Guardrail #3.
|
|
448
|
+
const fileSize = bundleSize;
|
|
408
449
|
|
|
409
450
|
return {
|
|
410
451
|
filePath: outputPath,
|
|
411
|
-
fileSize
|
|
452
|
+
fileSize,
|
|
412
453
|
projectPath,
|
|
413
454
|
metadata: {
|
|
414
455
|
title: input.title,
|
|
415
456
|
bundlePath,
|
|
416
457
|
projectPath,
|
|
417
458
|
hasIndexCss: typeof input.indexCss === 'string',
|
|
459
|
+
...(deps.length > 0 ? { deps } : {}),
|
|
418
460
|
extraFileCount: Object.keys(input.files ?? {}).length,
|
|
419
461
|
outputPreview: readFileSync(outputPath, 'utf-8').slice(0, 200),
|
|
420
462
|
},
|