pmx-canvas 0.1.14 → 0.1.16
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 +153 -0
- package/Readme.md +108 -1058
- package/dist/canvas/global.css +141 -0
- package/dist/canvas/index.js +124 -74
- package/dist/json-render/index.css +1 -1
- package/dist/types/client/nodes/ContextNode.d.ts +11 -2
- package/dist/types/client/nodes/HtmlNode.d.ts +5 -0
- package/dist/types/client/nodes/StatusNode.d.ts +1 -0
- package/dist/types/client/state/canvas-store.d.ts +11 -3
- package/dist/types/client/state/intent-bridge.d.ts +5 -1
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/json-render/catalog.d.ts +1 -1
- package/dist/types/mcp/canvas-access.d.ts +7 -1
- package/dist/types/server/agent-context.d.ts +1 -0
- package/dist/types/server/canvas-operations.d.ts +4 -2
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-serialization.d.ts +3 -0
- package/dist/types/server/canvas-state.d.ts +51 -4
- package/dist/types/server/demo.d.ts +5 -0
- package/dist/types/server/index.d.ts +13 -3
- package/dist/types/server/web-artifacts.d.ts +18 -0
- package/dist/types/shared/canvas-node-kind.d.ts +5 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +43 -0
- package/skills/pmx-canvas-testing/SKILL.md +17 -0
- package/src/cli/agent.ts +52 -5
- package/src/cli/index.ts +2 -23
- package/src/client/canvas/AttentionHistory.tsx +14 -1
- package/src/client/canvas/CanvasNode.tsx +1 -1
- package/src/client/canvas/CanvasViewport.tsx +3 -0
- package/src/client/canvas/ContextPinBar.tsx +2 -1
- package/src/client/canvas/DockedNode.tsx +112 -13
- package/src/client/canvas/ExpandedNodeOverlay.tsx +5 -0
- package/src/client/canvas/Minimap.tsx +1 -0
- package/src/client/icons.tsx +1 -0
- package/src/client/nodes/ContextNode.tsx +128 -6
- package/src/client/nodes/HtmlNode.tsx +151 -0
- package/src/client/nodes/StatusNode.tsx +16 -1
- package/src/client/nodes/StatusSummary.tsx +2 -1
- package/src/client/state/canvas-store.ts +37 -7
- package/src/client/state/intent-bridge.ts +9 -4
- package/src/client/state/sse-bridge.ts +2 -1
- package/src/client/theme/global.css +141 -0
- package/src/client/types.ts +3 -0
- package/src/mcp/canvas-access.ts +34 -7
- package/src/mcp/server.ts +178 -25
- package/src/server/agent-context.ts +50 -3
- package/src/server/canvas-operations.ts +20 -3
- package/src/server/canvas-provenance.ts +2 -1
- package/src/server/canvas-serialization.ts +38 -13
- package/src/server/canvas-state.ts +305 -34
- package/src/server/demo.ts +792 -0
- package/src/server/index.ts +33 -3
- package/src/server/server.ts +98 -14
- package/src/server/web-artifacts.ts +116 -3
- package/src/shared/canvas-node-kind.ts +14 -0
package/src/server/index.ts
CHANGED
|
@@ -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,
|
|
@@ -459,8 +460,8 @@ export class PmxCanvas extends EventEmitter {
|
|
|
459
460
|
return result;
|
|
460
461
|
}
|
|
461
462
|
|
|
462
|
-
listSnapshots() {
|
|
463
|
-
return listCanvasSnapshots();
|
|
463
|
+
listSnapshots(options?: Parameters<typeof listCanvasSnapshots>[0]) {
|
|
464
|
+
return listCanvasSnapshots(options);
|
|
464
465
|
}
|
|
465
466
|
|
|
466
467
|
saveSnapshot(name: string) {
|
|
@@ -479,6 +480,10 @@ export class PmxCanvas extends EventEmitter {
|
|
|
479
480
|
return deleteCanvasSnapshot(id);
|
|
480
481
|
}
|
|
481
482
|
|
|
483
|
+
gcSnapshots(options?: Parameters<typeof gcCanvasSnapshots>[0]): ReturnType<typeof gcCanvasSnapshots> {
|
|
484
|
+
return gcCanvasSnapshots(options);
|
|
485
|
+
}
|
|
486
|
+
|
|
482
487
|
diffSnapshot(idOrName: string): { ok: boolean; text?: string; diff?: ReturnType<typeof diffLayouts>; error?: string } {
|
|
483
488
|
const snapData = canvasState.getSnapshotData(idOrName);
|
|
484
489
|
if (!snapData) return { ok: false, error: `Snapshot "${idOrName}" not found` };
|
|
@@ -619,6 +624,31 @@ export class PmxCanvas extends EventEmitter {
|
|
|
619
624
|
return result;
|
|
620
625
|
}
|
|
621
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
|
+
|
|
622
652
|
addGraphNode(input: GraphNodeInput): { id: string; url: string; spec: JsonRenderSpec } {
|
|
623
653
|
const result = createCanvasGraphNode(input);
|
|
624
654
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
@@ -693,7 +723,7 @@ export {
|
|
|
693
723
|
screenshotCanvasAutomationWebView,
|
|
694
724
|
} from './server.js';
|
|
695
725
|
export { canvasState } from './canvas-state.js';
|
|
696
|
-
export type { CanvasSnapshot } from './canvas-state.js';
|
|
726
|
+
export type { CanvasSnapshot, CanvasSnapshotGcResult, CanvasSnapshotListOptions } from './canvas-state.js';
|
|
697
727
|
export { findOpenCanvasPosition } from './placement.js';
|
|
698
728
|
export { searchNodes, buildSpatialContext, detectClusters, findNeighborhoods } from './spatial-analysis.js';
|
|
699
729
|
export type { SpatialCluster, SpatialContext, SpatialNeighbor, NodeSpatialInfo } from './spatial-analysis.js';
|
package/src/server/server.ts
CHANGED
|
@@ -65,7 +65,13 @@ import {
|
|
|
65
65
|
import { findOpenCanvasPosition, computeGroupBounds } from './placement.js';
|
|
66
66
|
import { searchNodes, buildSpatialContext } from './spatial-analysis.js';
|
|
67
67
|
import { diffLayouts, formatDiff, mutationHistory } from './mutation-history.js';
|
|
68
|
-
import {
|
|
68
|
+
import {
|
|
69
|
+
buildCanvasSummary,
|
|
70
|
+
serializeCanvasLayout,
|
|
71
|
+
serializeCanvasLayoutWithBlobSummaries,
|
|
72
|
+
serializeCanvasNode,
|
|
73
|
+
serializeCanvasNodeWithBlobSummaries,
|
|
74
|
+
} from './canvas-serialization.js';
|
|
69
75
|
import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
|
|
70
76
|
import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
|
|
71
77
|
import { validateLocalImageFile } from './image-source.js';
|
|
@@ -82,6 +88,7 @@ import {
|
|
|
82
88
|
deleteCanvasSnapshot,
|
|
83
89
|
executeCanvasBatch,
|
|
84
90
|
fitCanvasView,
|
|
91
|
+
gcCanvasSnapshots,
|
|
85
92
|
groupCanvasNodes,
|
|
86
93
|
listCanvasSnapshots,
|
|
87
94
|
refreshCanvasWebpageNode,
|
|
@@ -91,6 +98,7 @@ import {
|
|
|
91
98
|
saveCanvasSnapshot,
|
|
92
99
|
scheduleCodeGraphRecompute,
|
|
93
100
|
primeCanvasRuntimeBackends,
|
|
101
|
+
setCanvasLayoutUpdateEmitter,
|
|
94
102
|
syncCanvasRuntimeBackends,
|
|
95
103
|
setCanvasContextPins,
|
|
96
104
|
ungroupCanvasNodes,
|
|
@@ -1186,7 +1194,15 @@ function serveCanvasFavicon(): Response {
|
|
|
1186
1194
|
async function handleCanvasUpdate(req: Request): Promise<Response> {
|
|
1187
1195
|
const body = await readJson(req);
|
|
1188
1196
|
const updates = Array.isArray(body.updates) ? body.updates : [];
|
|
1189
|
-
const result =
|
|
1197
|
+
const result = body.recordHistory === false
|
|
1198
|
+
? (() => {
|
|
1199
|
+
let suppressedResult: ReturnType<typeof applyCanvasNodeUpdates> = { applied: 0, skipped: updates.length };
|
|
1200
|
+
canvasState.withSuppressedRecording(() => {
|
|
1201
|
+
suppressedResult = applyCanvasNodeUpdates(updates);
|
|
1202
|
+
});
|
|
1203
|
+
return suppressedResult;
|
|
1204
|
+
})()
|
|
1205
|
+
: applyCanvasNodeUpdates(updates);
|
|
1190
1206
|
if (result.applied > 0) {
|
|
1191
1207
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1192
1208
|
}
|
|
@@ -1200,7 +1216,13 @@ async function handleCanvasViewport(req: Request): Promise<Response> {
|
|
|
1200
1216
|
y: typeof body.y === 'number' ? body.y : canvasState.viewport.y,
|
|
1201
1217
|
scale: typeof body.scale === 'number' ? body.scale : canvasState.viewport.scale,
|
|
1202
1218
|
};
|
|
1203
|
-
|
|
1219
|
+
if (body.recordHistory === false) {
|
|
1220
|
+
canvasState.withSuppressedRecording(() => {
|
|
1221
|
+
canvasState.setViewport(next);
|
|
1222
|
+
});
|
|
1223
|
+
} else {
|
|
1224
|
+
canvasState.setViewport(next);
|
|
1225
|
+
}
|
|
1204
1226
|
emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
|
|
1205
1227
|
return responseJson({ ok: true });
|
|
1206
1228
|
}
|
|
@@ -1236,7 +1258,7 @@ async function handleCanvasImage(pathname: string): Promise<Response> {
|
|
|
1236
1258
|
}
|
|
1237
1259
|
|
|
1238
1260
|
// ── Add node from client ─────────────────────────────────────
|
|
1239
|
-
const VALID_NODE_TYPES = new Set(['markdown', 'status', 'context', 'ledger', 'trace', 'file', 'image', 'mcp-app', 'webpage', 'group']);
|
|
1261
|
+
const VALID_NODE_TYPES = new Set(['markdown', 'status', 'context', 'ledger', 'trace', 'file', 'image', 'mcp-app', 'webpage', 'html', 'group']);
|
|
1240
1262
|
|
|
1241
1263
|
function buildNodeResponse(node: CanvasNodeState): Record<string, unknown> {
|
|
1242
1264
|
const serialized = serializeCanvasNode(node);
|
|
@@ -1322,9 +1344,22 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
|
|
|
1322
1344
|
const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
|
|
1323
1345
|
? body.data as Record<string, unknown>
|
|
1324
1346
|
: undefined;
|
|
1347
|
+
if (type === 'html') {
|
|
1348
|
+
if ('html' in body && typeof body.html !== 'string') {
|
|
1349
|
+
return responseJson({ ok: false, error: 'HTML node field "html" must be a string.' }, 400);
|
|
1350
|
+
}
|
|
1351
|
+
if (extraData && 'html' in extraData && typeof extraData.html !== 'string') {
|
|
1352
|
+
return responseJson({ ok: false, error: 'HTML node field "data.html" must be a string.' }, 400);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1325
1355
|
const content = type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string'
|
|
1326
1356
|
? body.path
|
|
1327
1357
|
: body.content;
|
|
1358
|
+
// For html nodes, accept top-level `html` field and merge into data so callers
|
|
1359
|
+
// can POST { type: 'html', title, html } without nesting under `data`.
|
|
1360
|
+
const htmlMergedData = type === 'html' && typeof body.html === 'string'
|
|
1361
|
+
? { ...(extraData ?? {}), html: body.html }
|
|
1362
|
+
: extraData;
|
|
1328
1363
|
let added: ReturnType<typeof addCanvasNode>;
|
|
1329
1364
|
const geometry = resolveCreateGeometry(body);
|
|
1330
1365
|
try {
|
|
@@ -1332,7 +1367,7 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
|
|
|
1332
1367
|
type: type as CanvasNodeState['type'],
|
|
1333
1368
|
...(typeof body.title === 'string' ? { title: body.title } : {}),
|
|
1334
1369
|
...(typeof content === 'string' ? { content } : {}),
|
|
1335
|
-
...(
|
|
1370
|
+
...(htmlMergedData ? { data: htmlMergedData } : {}),
|
|
1336
1371
|
...(type === 'trace' && typeof body.toolName === 'string' ? { toolName: body.toolName } : {}),
|
|
1337
1372
|
...(type === 'trace' && typeof body.category === 'string' ? { category: body.category } : {}),
|
|
1338
1373
|
...(type === 'trace' && typeof body.status === 'string' ? { status: body.status } : {}),
|
|
@@ -1341,8 +1376,8 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
|
|
|
1341
1376
|
...(type === 'trace' && typeof body.error === 'string' ? { error: body.error } : {}),
|
|
1342
1377
|
...(body.strictSize === true ? { strictSize: true } : {}),
|
|
1343
1378
|
...geometry,
|
|
1344
|
-
defaultWidth: 360,
|
|
1345
|
-
defaultHeight: 200,
|
|
1379
|
+
defaultWidth: type === 'html' ? 720 : 360,
|
|
1380
|
+
defaultHeight: type === 'html' ? 640 : 200,
|
|
1346
1381
|
fileMode: 'auto',
|
|
1347
1382
|
});
|
|
1348
1383
|
} catch (error) {
|
|
@@ -1374,6 +1409,15 @@ async function handleCanvasCreateGroup(req: Request): Promise<Response> {
|
|
|
1374
1409
|
body.childLayout === 'grid' || body.childLayout === 'column' || body.childLayout === 'flow'
|
|
1375
1410
|
? body.childLayout
|
|
1376
1411
|
: undefined;
|
|
1412
|
+
if (childIds.length > 0) {
|
|
1413
|
+
const missingChildIds = childIds.filter((id) => !canvasState.getNode(id));
|
|
1414
|
+
if (missingChildIds.length > 0) {
|
|
1415
|
+
return responseJson({
|
|
1416
|
+
ok: false,
|
|
1417
|
+
error: `Cannot create group: missing child node ID${missingChildIds.length === 1 ? '' : 's'}: ${missingChildIds.join(', ')}.`,
|
|
1418
|
+
}, 400);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1377
1421
|
|
|
1378
1422
|
const { node } = createCanvasGroup({ title, childIds, color, x, y, width, height, ...(childLayout ? { childLayout } : {}) });
|
|
1379
1423
|
|
|
@@ -1656,7 +1700,10 @@ async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
|
|
|
1656
1700
|
bytes: result.fileSize,
|
|
1657
1701
|
projectPath: result.projectPath,
|
|
1658
1702
|
openedInCanvas: result.openedInCanvas,
|
|
1703
|
+
startedAt: result.startedAt,
|
|
1659
1704
|
completedAt: result.completedAt,
|
|
1705
|
+
durationMs: result.durationMs,
|
|
1706
|
+
timeoutMs: result.timeoutMs,
|
|
1660
1707
|
// `id` is the canvas node id alias used by every other add-style
|
|
1661
1708
|
// response. It is only present when a canvas node was actually
|
|
1662
1709
|
// created (i.e. openInCanvas was not explicitly disabled). When
|
|
@@ -1905,6 +1952,13 @@ function responseText(text: string, status = 400): Response {
|
|
|
1905
1952
|
});
|
|
1906
1953
|
}
|
|
1907
1954
|
|
|
1955
|
+
function parsePositiveIntegerParam(value: string | null): number | undefined {
|
|
1956
|
+
if (!value) return undefined;
|
|
1957
|
+
const parsed = Number(value);
|
|
1958
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
|
|
1959
|
+
return Math.floor(parsed);
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1908
1962
|
function handleArtifactView(url: URL): Response {
|
|
1909
1963
|
const pathLike = url.searchParams.get('path') ?? '';
|
|
1910
1964
|
const safePath = resolveWorkspaceArtifactPath(pathLike);
|
|
@@ -3085,6 +3139,18 @@ async function handleSnapshotSave(req: Request): Promise<Response> {
|
|
|
3085
3139
|
return responseJson({ ok: true, id: snapshot.id, snapshot });
|
|
3086
3140
|
}
|
|
3087
3141
|
|
|
3142
|
+
async function handleSnapshotGc(req: Request): Promise<Response> {
|
|
3143
|
+
const body = await readJson(req);
|
|
3144
|
+
const keepValue = body.keep;
|
|
3145
|
+
const keep = typeof keepValue === 'number'
|
|
3146
|
+
? keepValue
|
|
3147
|
+
: typeof keepValue === 'string'
|
|
3148
|
+
? Number(keepValue)
|
|
3149
|
+
: undefined;
|
|
3150
|
+
const dryRun = body.dryRun === true || body['dry-run'] === true;
|
|
3151
|
+
return responseJson(gcCanvasSnapshots({ keep, dryRun }));
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3088
3154
|
async function handleContextPinsUpdate(req: Request): Promise<Response> {
|
|
3089
3155
|
const body = await readJson(req);
|
|
3090
3156
|
const MAX_PINS = 20;
|
|
@@ -3325,9 +3391,9 @@ function syncContextNodeToCanvasState(
|
|
|
3325
3391
|
position: { x: 1130, y: 80 },
|
|
3326
3392
|
size: { width: 320, height: 400 },
|
|
3327
3393
|
zIndex: 1,
|
|
3328
|
-
collapsed:
|
|
3394
|
+
collapsed: true,
|
|
3329
3395
|
pinned: false,
|
|
3330
|
-
dockPosition:
|
|
3396
|
+
dockPosition: 'right',
|
|
3331
3397
|
data: mergedData,
|
|
3332
3398
|
});
|
|
3333
3399
|
return;
|
|
@@ -3800,6 +3866,9 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3800
3866
|
// ── Canvas persistence: set workspace root and load saved state ──
|
|
3801
3867
|
canvasState.setWorkspaceRoot(activeWorkspaceRoot);
|
|
3802
3868
|
const loaded = canvasState.loadFromDisk({ clearExisting: true });
|
|
3869
|
+
setCanvasLayoutUpdateEmitter(() => {
|
|
3870
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
3871
|
+
});
|
|
3803
3872
|
if (loaded) {
|
|
3804
3873
|
console.log(' Canvas state restored from .pmx-canvas/state.json');
|
|
3805
3874
|
primeCanvasRuntimeBackends({ forceRehydrateExtApps: true });
|
|
@@ -3903,7 +3972,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3903
3972
|
|
|
3904
3973
|
// Canvas state API
|
|
3905
3974
|
if (url.pathname === '/api/canvas/state' && req.method === 'GET') {
|
|
3906
|
-
|
|
3975
|
+
const includeBlobs = url.searchParams.get('includeBlobs') === 'true';
|
|
3976
|
+
return responseJson(includeBlobs
|
|
3977
|
+
? serializeCanvasLayout(canvasState.getLayout())
|
|
3978
|
+
: serializeCanvasLayoutWithBlobSummaries(canvasState.getLayoutForPersistence()));
|
|
3907
3979
|
}
|
|
3908
3980
|
|
|
3909
3981
|
if (url.pathname === '/api/canvas/summary' && req.method === 'GET') {
|
|
@@ -3953,10 +4025,13 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3953
4025
|
}
|
|
3954
4026
|
|
|
3955
4027
|
if (url.pathname.startsWith('/api/canvas/node/') && req.method === 'GET') {
|
|
3956
|
-
const nodeId = url.pathname.slice('/api/canvas/node/'.length);
|
|
3957
|
-
const
|
|
4028
|
+
const nodeId = decodeURIComponent(url.pathname.slice('/api/canvas/node/'.length));
|
|
4029
|
+
const includeBlobs = url.searchParams.get('includeBlobs') === 'true';
|
|
4030
|
+
const node = includeBlobs ? canvasState.getNode(nodeId) : canvasState.getNodeForPersistence(nodeId);
|
|
3958
4031
|
if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
|
|
3959
|
-
return responseJson(
|
|
4032
|
+
return responseJson(includeBlobs
|
|
4033
|
+
? serializeCanvasNode(node)
|
|
4034
|
+
: serializeCanvasNodeWithBlobSummaries(node));
|
|
3960
4035
|
}
|
|
3961
4036
|
|
|
3962
4037
|
if (url.pathname.startsWith('/api/canvas/node/') && req.method === 'PATCH') {
|
|
@@ -3992,13 +4067,21 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3992
4067
|
|
|
3993
4068
|
// Snapshot API
|
|
3994
4069
|
if (url.pathname === '/api/canvas/snapshots' && req.method === 'GET') {
|
|
3995
|
-
return responseJson(listCanvasSnapshots(
|
|
4070
|
+
return responseJson(listCanvasSnapshots({
|
|
4071
|
+
limit: parsePositiveIntegerParam(url.searchParams.get('limit')),
|
|
4072
|
+
query: url.searchParams.get('q') ?? url.searchParams.get('query') ?? undefined,
|
|
4073
|
+
all: url.searchParams.get('all') === 'true',
|
|
4074
|
+
}));
|
|
3996
4075
|
}
|
|
3997
4076
|
|
|
3998
4077
|
if (url.pathname === '/api/canvas/snapshots' && req.method === 'POST') {
|
|
3999
4078
|
return handleSnapshotSave(req);
|
|
4000
4079
|
}
|
|
4001
4080
|
|
|
4081
|
+
if (url.pathname === '/api/canvas/snapshots/gc' && req.method === 'POST') {
|
|
4082
|
+
return handleSnapshotGc(req);
|
|
4083
|
+
}
|
|
4084
|
+
|
|
4002
4085
|
if (url.pathname.startsWith('/api/canvas/snapshots/') && url.pathname.endsWith('/diff') && req.method === 'GET') {
|
|
4003
4086
|
const id = decodeURIComponent(url.pathname.slice('/api/canvas/snapshots/'.length, -'/diff'.length));
|
|
4004
4087
|
const snapshot = canvasState.getSnapshotData(id);
|
|
@@ -4182,6 +4265,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
4182
4265
|
export function stopCanvasServer(): void {
|
|
4183
4266
|
canvasState.flushToDisk();
|
|
4184
4267
|
closeAllMcpAppSessions();
|
|
4268
|
+
setCanvasLayoutUpdateEmitter(null);
|
|
4185
4269
|
void closeCanvasAutomationWebViewInternal().catch((error) => {
|
|
4186
4270
|
logWorkbenchWarning('stopCanvasServer closeCanvasAutomationWebViewInternal', error);
|
|
4187
4271
|
});
|
|
@@ -28,6 +28,11 @@ const DEFAULT_TIMEOUT_MS = 10 * 60_000;
|
|
|
28
28
|
const DEFAULT_PACKAGE_MANAGER = 'pnpm@10.33.0';
|
|
29
29
|
const DEFAULT_WEB_ARTIFACT_NODE_SIZE = { width: 960, height: 720 };
|
|
30
30
|
const FALLBACK_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'];
|
|
31
|
+
const WEB_ARTIFACT_CONTEXT_MAX_LENGTH = 1200;
|
|
32
|
+
const WEB_ARTIFACT_SOURCE_PREVIEW_MAX_LENGTH = 700;
|
|
33
|
+
const WEB_ARTIFACT_CSS_PREVIEW_MAX_LENGTH = 260;
|
|
34
|
+
const WEB_ARTIFACT_MAX_LISTED_SOURCE_FILES = 8;
|
|
35
|
+
const WEB_ARTIFACT_MAX_STORED_SOURCE_FILES = 32;
|
|
31
36
|
|
|
32
37
|
export interface WebArtifactBuildInput {
|
|
33
38
|
title: string;
|
|
@@ -49,6 +54,7 @@ export interface WebArtifactBuildOutput {
|
|
|
49
54
|
fileSize: number;
|
|
50
55
|
projectPath: string;
|
|
51
56
|
metadata: Record<string, unknown>;
|
|
57
|
+
sourceContext: WebArtifactSourceContext;
|
|
52
58
|
logs?: {
|
|
53
59
|
stdout?: WebArtifactLogSummary;
|
|
54
60
|
stderr?: WebArtifactLogSummary;
|
|
@@ -64,6 +70,14 @@ export interface WebArtifactLogSummary {
|
|
|
64
70
|
suppressedNoiseCount: number;
|
|
65
71
|
}
|
|
66
72
|
|
|
73
|
+
export interface WebArtifactSourceContext {
|
|
74
|
+
content: string;
|
|
75
|
+
sourceFiles: string[];
|
|
76
|
+
sourceFileCount: number;
|
|
77
|
+
sourcePreview: string;
|
|
78
|
+
deps?: string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
67
81
|
export interface WebArtifactCanvasOpenResult {
|
|
68
82
|
nodeId: string;
|
|
69
83
|
url: string;
|
|
@@ -73,7 +87,10 @@ export interface WebArtifactCanvasBuildResult extends WebArtifactBuildOutput {
|
|
|
73
87
|
openedInCanvas: boolean;
|
|
74
88
|
nodeId?: string;
|
|
75
89
|
url?: string;
|
|
90
|
+
startedAt: string;
|
|
76
91
|
completedAt: string;
|
|
92
|
+
durationMs: number;
|
|
93
|
+
timeoutMs: number;
|
|
77
94
|
}
|
|
78
95
|
|
|
79
96
|
function currentWorkspaceRoot(): string {
|
|
@@ -114,6 +131,67 @@ function slugify(value: string): string {
|
|
|
114
131
|
return slug.length > 0 ? slug : 'web-artifact';
|
|
115
132
|
}
|
|
116
133
|
|
|
134
|
+
function normalizePreviewText(text: string): string {
|
|
135
|
+
return text
|
|
136
|
+
.replace(/\r\n?/g, '\n')
|
|
137
|
+
.replace(/[ \t\f\v]+/g, ' ')
|
|
138
|
+
.replace(/\n\s+/g, '\n')
|
|
139
|
+
.replace(/\s+\n/g, '\n')
|
|
140
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
141
|
+
.trim();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function truncatePreviewText(text: string, maxLength: number): string {
|
|
145
|
+
if (maxLength <= 0) return '';
|
|
146
|
+
const normalized = normalizePreviewText(text);
|
|
147
|
+
if (normalized.length <= maxLength) return normalized;
|
|
148
|
+
if (maxLength <= 3) return normalized.slice(0, maxLength);
|
|
149
|
+
return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function formatSourceFileList(files: string[]): string {
|
|
153
|
+
const visible = files.slice(0, WEB_ARTIFACT_MAX_LISTED_SOURCE_FILES);
|
|
154
|
+
const remaining = files.length - visible.length;
|
|
155
|
+
return `${visible.join(', ')}${remaining > 0 ? `, +${remaining} more` : ''}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildWebArtifactSourceContext(
|
|
159
|
+
input: Pick<WebArtifactBuildInput, 'title' | 'appTsx' | 'indexCss' | 'mainTsx' | 'indexHtml' | 'files'>,
|
|
160
|
+
options: { deps: string[]; fileSize: number },
|
|
161
|
+
): WebArtifactSourceContext {
|
|
162
|
+
const sourceFiles = new Set<string>(['src/App.tsx']);
|
|
163
|
+
if (typeof input.indexCss === 'string') sourceFiles.add('src/index.css');
|
|
164
|
+
if (typeof input.mainTsx === 'string') sourceFiles.add('src/main.tsx');
|
|
165
|
+
if (typeof input.indexHtml === 'string') sourceFiles.add('index.html');
|
|
166
|
+
for (const pathKey of Object.keys(input.files ?? {}).sort()) {
|
|
167
|
+
sourceFiles.add(pathKey);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const appPreview = truncatePreviewText(input.appTsx, WEB_ARTIFACT_SOURCE_PREVIEW_MAX_LENGTH);
|
|
171
|
+
const cssPreview = typeof input.indexCss === 'string'
|
|
172
|
+
? truncatePreviewText(input.indexCss, WEB_ARTIFACT_CSS_PREVIEW_MAX_LENGTH)
|
|
173
|
+
: '';
|
|
174
|
+
const allSourceFiles = [...sourceFiles];
|
|
175
|
+
const storedSourceFiles = allSourceFiles.slice(0, WEB_ARTIFACT_MAX_STORED_SOURCE_FILES);
|
|
176
|
+
const parts = [
|
|
177
|
+
`Web artifact: ${input.title}`,
|
|
178
|
+
`Source files: ${formatSourceFileList(allSourceFiles)}`,
|
|
179
|
+
`Artifact bytes: ${options.fileSize}`,
|
|
180
|
+
options.deps.length > 0 ? `Dependencies: ${options.deps.join(', ')}` : '',
|
|
181
|
+
`App source preview:\n${appPreview}`,
|
|
182
|
+
cssPreview ? `CSS source preview:\n${cssPreview}` : '',
|
|
183
|
+
].filter(Boolean);
|
|
184
|
+
|
|
185
|
+
const content = truncatePreviewText(parts.join('\n'), WEB_ARTIFACT_CONTEXT_MAX_LENGTH);
|
|
186
|
+
return {
|
|
187
|
+
content,
|
|
188
|
+
sourceFiles: storedSourceFiles,
|
|
189
|
+
sourceFileCount: allSourceFiles.length,
|
|
190
|
+
sourcePreview: appPreview,
|
|
191
|
+
...(options.deps.length > 0 ? { deps: options.deps } : {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
117
195
|
function isPathInside(base: string, candidate: string): boolean {
|
|
118
196
|
const rel = relative(base, candidate);
|
|
119
197
|
if (rel === '') return true;
|
|
@@ -458,19 +536,23 @@ export async function executeWebArtifactBuild(
|
|
|
458
536
|
// on a filesystem failure. A post-copy size check would be redundant
|
|
459
537
|
// defensive noise — see CLAUDE.md TypeScript Guardrail #3.
|
|
460
538
|
const fileSize = bundleSize;
|
|
539
|
+
const sourceContext = buildWebArtifactSourceContext(input, { deps, fileSize });
|
|
461
540
|
|
|
462
541
|
return {
|
|
463
542
|
filePath: outputPath,
|
|
464
543
|
fileSize,
|
|
465
544
|
projectPath,
|
|
545
|
+
sourceContext,
|
|
466
546
|
metadata: {
|
|
467
547
|
title: input.title,
|
|
468
548
|
bundlePath,
|
|
469
549
|
projectPath,
|
|
550
|
+
sourceFiles: sourceContext.sourceFiles,
|
|
551
|
+
sourceFileCount: sourceContext.sourceFileCount,
|
|
552
|
+
sourcePreview: sourceContext.sourcePreview,
|
|
470
553
|
hasIndexCss: typeof input.indexCss === 'string',
|
|
471
554
|
...(deps.length > 0 ? { deps } : {}),
|
|
472
555
|
extraFileCount: Object.keys(input.files ?? {}).length,
|
|
473
|
-
outputPreview: readFileSync(outputPath, 'utf-8').slice(0, 200),
|
|
474
556
|
},
|
|
475
557
|
logs: {
|
|
476
558
|
...(summarizeArtifactLog(stdout) ? { stdout: summarizeArtifactLog(stdout) } : {}),
|
|
@@ -484,6 +566,13 @@ export async function executeWebArtifactBuild(
|
|
|
484
566
|
export function openWebArtifactInCanvas(input: {
|
|
485
567
|
title: string;
|
|
486
568
|
filePath: string;
|
|
569
|
+
fileSize?: number;
|
|
570
|
+
projectPath?: string;
|
|
571
|
+
content?: string;
|
|
572
|
+
sourceFiles?: string[];
|
|
573
|
+
sourceFileCount?: number;
|
|
574
|
+
sourcePreview?: string;
|
|
575
|
+
deps?: string[];
|
|
487
576
|
}): WebArtifactCanvasOpenResult {
|
|
488
577
|
const width = DEFAULT_WEB_ARTIFACT_NODE_SIZE.width;
|
|
489
578
|
const height = DEFAULT_WEB_ARTIFACT_NODE_SIZE.height;
|
|
@@ -501,8 +590,15 @@ export function openWebArtifactInCanvas(input: {
|
|
|
501
590
|
dockPosition: null,
|
|
502
591
|
data: {
|
|
503
592
|
title: input.title,
|
|
593
|
+
...(typeof input.content === 'string' ? { content: input.content } : {}),
|
|
504
594
|
url,
|
|
505
595
|
path: input.filePath,
|
|
596
|
+
...(typeof input.fileSize === 'number' ? { artifactBytes: input.fileSize } : {}),
|
|
597
|
+
...(typeof input.projectPath === 'string' ? { projectPath: input.projectPath } : {}),
|
|
598
|
+
...(Array.isArray(input.sourceFiles) ? { sourceFiles: input.sourceFiles } : {}),
|
|
599
|
+
...(typeof input.sourceFileCount === 'number' ? { sourceFileCount: input.sourceFileCount } : {}),
|
|
600
|
+
...(typeof input.sourcePreview === 'string' ? { sourcePreview: input.sourcePreview } : {}),
|
|
601
|
+
...(Array.isArray(input.deps) && input.deps.length > 0 ? { deps: input.deps } : {}),
|
|
506
602
|
trustedDomain: true,
|
|
507
603
|
sourceServer: 'pmx-canvas',
|
|
508
604
|
hostMode: 'hosted',
|
|
@@ -518,19 +614,36 @@ export function openWebArtifactInCanvas(input: {
|
|
|
518
614
|
export async function buildWebArtifactOnCanvas(input: WebArtifactBuildInput & {
|
|
519
615
|
openInCanvas?: boolean;
|
|
520
616
|
}): Promise<WebArtifactCanvasBuildResult> {
|
|
617
|
+
const startedMs = Date.now();
|
|
618
|
+
const startedAt = new Date(startedMs).toISOString();
|
|
619
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
521
620
|
const build = await executeWebArtifactBuild(input);
|
|
621
|
+
const completedMs = Date.now();
|
|
622
|
+
const timing = {
|
|
623
|
+
startedAt,
|
|
624
|
+
completedAt: new Date(completedMs).toISOString(),
|
|
625
|
+
durationMs: completedMs - startedMs,
|
|
626
|
+
timeoutMs,
|
|
627
|
+
};
|
|
522
628
|
if (input.openInCanvas === false) {
|
|
523
|
-
return { ...build, openedInCanvas: false,
|
|
629
|
+
return { ...build, openedInCanvas: false, ...timing };
|
|
524
630
|
}
|
|
525
631
|
const opened = openWebArtifactInCanvas({
|
|
526
632
|
title: input.title,
|
|
527
633
|
filePath: build.filePath,
|
|
634
|
+
fileSize: build.fileSize,
|
|
635
|
+
projectPath: build.projectPath,
|
|
636
|
+
content: build.sourceContext.content,
|
|
637
|
+
sourceFiles: build.sourceContext.sourceFiles,
|
|
638
|
+
sourceFileCount: build.sourceContext.sourceFileCount,
|
|
639
|
+
sourcePreview: build.sourceContext.sourcePreview,
|
|
640
|
+
...(Array.isArray(build.sourceContext.deps) ? { deps: build.sourceContext.deps } : {}),
|
|
528
641
|
});
|
|
529
642
|
return {
|
|
530
643
|
...build,
|
|
531
644
|
openedInCanvas: true,
|
|
532
645
|
nodeId: opened.nodeId,
|
|
533
646
|
url: opened.url,
|
|
534
|
-
|
|
647
|
+
...timing,
|
|
535
648
|
};
|
|
536
649
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface CanvasNodeKindInput {
|
|
2
|
+
type: string;
|
|
3
|
+
data: Record<string, unknown>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function getCanvasNodeKind(node: CanvasNodeKindInput): string {
|
|
7
|
+
if (node.type !== 'mcp-app') return node.type;
|
|
8
|
+
|
|
9
|
+
const data = node.data;
|
|
10
|
+
if (data.viewerType === 'web-artifact') return 'web-artifact';
|
|
11
|
+
if (data.mode === 'ext-app') return 'external-app';
|
|
12
|
+
if (data.hostMode === 'hosted' && typeof data.path === 'string') return 'web-artifact';
|
|
13
|
+
return 'mcp-app';
|
|
14
|
+
}
|