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.
- package/CHANGELOG.md +144 -0
- package/Readme.md +35 -8
- package/dist/canvas/index.js +69 -69
- package/dist/json-render/index.css +1 -1
- package/dist/json-render/index.js +1 -1
- 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/json-render/charts/components.d.ts +2 -1
- package/dist/types/server/canvas-serialization.d.ts +1 -0
- package/dist/types/server/diagram-presets.d.ts +13 -0
- package/dist/types/server/ext-app-lookup.d.ts +22 -0
- package/dist/types/server/index.d.ts +8 -1
- package/dist/types/server/web-artifacts.d.ts +1 -0
- package/package.json +2 -1
- package/skills/pmx-canvas/SKILL.md +35 -10
- 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 +114 -21
- 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 +19 -4
- package/src/client/types.ts +12 -0
- package/src/json-render/charts/components.tsx +6 -4
- package/src/json-render/charts/extra-components.tsx +5 -5
- package/src/json-render/renderer/index.css +14 -0
- package/src/mcp/server.ts +44 -5
- package/src/server/canvas-operations.ts +43 -5
- package/src/server/canvas-schema.ts +16 -14
- package/src/server/canvas-serialization.ts +19 -1
- package/src/server/diagram-presets.ts +219 -4
- package/src/server/ext-app-lookup.ts +49 -0
- package/src/server/index.ts +33 -25
- package/src/server/server.ts +199 -45
- 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 +44 -1
package/src/server/server.ts
CHANGED
|
@@ -39,12 +39,14 @@ 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,
|
|
45
46
|
ListToolsResult,
|
|
46
47
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
47
48
|
import { type CanvasEdge, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
|
|
49
|
+
import { findCanvasExtAppNodeId as findCanvasExtAppNodeIdShared } from './ext-app-lookup.js';
|
|
48
50
|
import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
|
|
49
51
|
import { getMcpAppHostSnapshot } from './mcp-app-host.js';
|
|
50
52
|
import {
|
|
@@ -92,7 +94,16 @@ import {
|
|
|
92
94
|
} from './canvas-operations.js';
|
|
93
95
|
import { validateCanvasLayout } from './canvas-validation.js';
|
|
94
96
|
import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
|
|
95
|
-
import {
|
|
97
|
+
import {
|
|
98
|
+
EXCALIDRAW_READ_CHECKPOINT_TOOL,
|
|
99
|
+
EXCALIDRAW_SAVE_CHECKPOINT_TOOL,
|
|
100
|
+
buildExcalidrawCheckpointId,
|
|
101
|
+
buildExcalidrawOpenMcpAppInput,
|
|
102
|
+
buildExcalidrawRestoreCheckpointToolInput,
|
|
103
|
+
ensureExcalidrawCheckpointId,
|
|
104
|
+
getExcalidrawCheckpointIdFromToolResult,
|
|
105
|
+
isExcalidrawCreateView,
|
|
106
|
+
} from './diagram-presets.js';
|
|
96
107
|
import { traceManager } from './trace-manager.js';
|
|
97
108
|
import { buildWebArtifactOnCanvas, resolveWorkspacePath } from './web-artifacts.js';
|
|
98
109
|
import {
|
|
@@ -592,18 +603,102 @@ function getMarkdownPlacement(): { x: number; y: number } {
|
|
|
592
603
|
}
|
|
593
604
|
|
|
594
605
|
function findCanvasExtAppNodeId(toolCallId: string): string | null {
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
606
|
+
return findCanvasExtAppNodeIdShared(toolCallId, {
|
|
607
|
+
getNode: (id) => canvasState.getNode(id),
|
|
608
|
+
listNodes: () => canvasState.getLayout().nodes,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function isCheckpointToolName(toolName: string): boolean {
|
|
613
|
+
return toolName === EXCALIDRAW_SAVE_CHECKPOINT_TOOL || toolName === EXCALIDRAW_READ_CHECKPOINT_TOOL;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Decide whether a fresh `callServerTool` result should *replace* the
|
|
618
|
+
* canvas node's bootstrap-replay `toolResult`.
|
|
619
|
+
*
|
|
620
|
+
* The bootstrap-replay toolResult is what the server re-sends to the
|
|
621
|
+
* widget on reload to restore visible state. We only want to overwrite
|
|
622
|
+
* it when the new result genuinely carries widget state — `isError` or
|
|
623
|
+
* `structuredContent`. A plain-text result (e.g. `read_checkpoint`
|
|
624
|
+
* returning a string status, or any informational message) updates
|
|
625
|
+
* `appModelContext` for the agent's record but should *not* clobber the
|
|
626
|
+
* bootstrap entry, because doing so would replace the widget's restored
|
|
627
|
+
* state with conversational noise on the next reload.
|
|
628
|
+
*
|
|
629
|
+
* This separation is exercised by:
|
|
630
|
+
* - tests/unit/server-api.test.ts "keeps ext-app model context
|
|
631
|
+
* separate from the replayed tool result" (text-only result preserves
|
|
632
|
+
* bootstrap replay)
|
|
633
|
+
* - tests/unit/server-api.test.ts "app-only text tool results update
|
|
634
|
+
* model context without replacing bootstrap replay"
|
|
635
|
+
* - tests/unit/server-api.test.ts "rehydrates Excalidraw checkpoint
|
|
636
|
+
* replay after server restart" (structured-content result becomes
|
|
637
|
+
* the new bootstrap replay)
|
|
638
|
+
*/
|
|
639
|
+
function shouldReplayAppToolResult(toolName: string, result: CallToolResult): boolean {
|
|
640
|
+
void toolName;
|
|
641
|
+
return Boolean(result.isError || result.structuredContent);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
645
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function getExtAppNodeCheckpointId(node: CanvasNodeState): string {
|
|
649
|
+
const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
|
|
650
|
+
const storedCheckpointId = appCheckpoint?.id;
|
|
651
|
+
if (typeof storedCheckpointId === 'string' && storedCheckpointId.trim().length > 0) {
|
|
652
|
+
return storedCheckpointId.trim();
|
|
605
653
|
}
|
|
606
|
-
return
|
|
654
|
+
return getExcalidrawCheckpointIdFromToolResult(node.data.toolResult) ?? buildExcalidrawCheckpointId(node.id);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function getLocalExcalidrawCheckpointData(
|
|
658
|
+
node: CanvasNodeState,
|
|
659
|
+
args: Record<string, unknown> | undefined,
|
|
660
|
+
): string | null {
|
|
661
|
+
if (!isExcalidrawCreateView(node.data.serverName, node.data.toolName)) return null;
|
|
662
|
+
if (!isRecord(args) || typeof args.id !== 'string') return null;
|
|
663
|
+
if (args.id.trim() !== getExtAppNodeCheckpointId(node)) return null;
|
|
664
|
+
const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
|
|
665
|
+
const data = appCheckpoint?.data;
|
|
666
|
+
return typeof data === 'string' ? data : '';
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function persistExcalidrawCheckpointToNode(
|
|
670
|
+
nodeId: string,
|
|
671
|
+
node: CanvasNodeState,
|
|
672
|
+
args: Record<string, unknown> | undefined,
|
|
673
|
+
): boolean {
|
|
674
|
+
if (!isExcalidrawCreateView(node.data.serverName, node.data.toolName)) return false;
|
|
675
|
+
if (!isRecord(args) || typeof args.id !== 'string') return false;
|
|
676
|
+
const checkpointId = getExtAppNodeCheckpointId(node);
|
|
677
|
+
if (args.id.trim() !== checkpointId) return false;
|
|
678
|
+
|
|
679
|
+
const currentToolInput = isRecord(node.data.toolInput) ? node.data.toolInput : {};
|
|
680
|
+
const nextToolInput = {
|
|
681
|
+
...currentToolInput,
|
|
682
|
+
elements: buildExcalidrawRestoreCheckpointToolInput(checkpointId, args.data),
|
|
683
|
+
};
|
|
684
|
+
const currentToolResult = isRecord(node.data.toolResult)
|
|
685
|
+
? ensureExcalidrawCheckpointId(node.data.toolResult as CallToolResult, node.id, checkpointId)
|
|
686
|
+
: undefined;
|
|
687
|
+
|
|
688
|
+
canvasState.updateNode(nodeId, {
|
|
689
|
+
data: {
|
|
690
|
+
...node.data,
|
|
691
|
+
toolInput: nextToolInput,
|
|
692
|
+
...(currentToolResult ? { toolResult: currentToolResult } : {}),
|
|
693
|
+
appCheckpoint: {
|
|
694
|
+
toolName: EXCALIDRAW_SAVE_CHECKPOINT_TOOL,
|
|
695
|
+
id: checkpointId,
|
|
696
|
+
...(typeof args.data === 'string' ? { data: args.data } : {}),
|
|
697
|
+
updatedAt: new Date().toISOString(),
|
|
698
|
+
},
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
return true;
|
|
607
702
|
}
|
|
608
703
|
|
|
609
704
|
function findReusableCanvasExtAppNodeId(serverName: string, toolName: string): string | null {
|
|
@@ -1323,7 +1418,13 @@ async function handleCanvasArrange(req: Request): Promise<Response> {
|
|
|
1323
1418
|
}
|
|
1324
1419
|
const result = arrangeCanvasNodes(layout as 'grid' | 'column' | 'flow');
|
|
1325
1420
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1326
|
-
|
|
1421
|
+
const validation = validateCanvasLayout(canvasState.getLayout());
|
|
1422
|
+
return responseJson({
|
|
1423
|
+
ok: validation.ok,
|
|
1424
|
+
arranged: result.arranged,
|
|
1425
|
+
layout: result.layout,
|
|
1426
|
+
...(validation.ok ? {} : { validation, collisions: validation.summary.collisions }),
|
|
1427
|
+
});
|
|
1327
1428
|
}
|
|
1328
1429
|
|
|
1329
1430
|
// ── Focus on node ───────────────────────────────────────────
|
|
@@ -1333,11 +1434,17 @@ async function handleCanvasFocus(req: Request): Promise<Response> {
|
|
|
1333
1434
|
if (!nodeId) return responseJson({ ok: false, error: 'Missing id.' }, 400);
|
|
1334
1435
|
const node = canvasState.getNode(nodeId);
|
|
1335
1436
|
if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1437
|
+
const noPan = body.noPan === true;
|
|
1438
|
+
if (!noPan) {
|
|
1439
|
+
canvasState.setViewport({ x: node.position.x - 100, y: node.position.y - 100 });
|
|
1440
|
+
} else {
|
|
1441
|
+
const maxZ = canvasState.getLayout().nodes.reduce((max, layoutNode) => Math.max(max, layoutNode.zIndex), 0);
|
|
1442
|
+
canvasState.updateNode(nodeId, { zIndex: maxZ + 1 });
|
|
1443
|
+
}
|
|
1444
|
+
emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId, noPan });
|
|
1445
|
+
if (!noPan) emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
|
|
1339
1446
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1340
|
-
return responseJson({ ok: true, focused: nodeId });
|
|
1447
|
+
return responseJson({ ok: true, focused: nodeId, panned: !noPan });
|
|
1341
1448
|
}
|
|
1342
1449
|
|
|
1343
1450
|
async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
|
|
@@ -1375,6 +1482,9 @@ async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
|
|
|
1375
1482
|
...(typeof body.bundleScriptPath === 'string'
|
|
1376
1483
|
? { bundleScriptPath: body.bundleScriptPath }
|
|
1377
1484
|
: {}),
|
|
1485
|
+
...(Array.isArray(body.deps)
|
|
1486
|
+
? { deps: body.deps.filter((dep): dep is string => typeof dep === 'string') }
|
|
1487
|
+
: {}),
|
|
1378
1488
|
...(typeof body.timeoutMs === 'number' ? { timeoutMs: body.timeoutMs } : {}),
|
|
1379
1489
|
...(typeof body.openInCanvas === 'boolean' ? { openInCanvas: body.openInCanvas } : {}),
|
|
1380
1490
|
});
|
|
@@ -1447,10 +1557,22 @@ async function handleCanvasValidateSpec(req: Request): Promise<Response> {
|
|
|
1447
1557
|
data,
|
|
1448
1558
|
...(typeof body.xKey === 'string' ? { xKey: body.xKey } : {}),
|
|
1449
1559
|
...(typeof body.yKey === 'string' ? { yKey: body.yKey } : {}),
|
|
1560
|
+
...(typeof body.zKey === 'string' ? { zKey: body.zKey } : {}),
|
|
1450
1561
|
...(typeof body.nameKey === 'string' ? { nameKey: body.nameKey } : {}),
|
|
1451
1562
|
...(typeof body.valueKey === 'string' ? { valueKey: body.valueKey } : {}),
|
|
1563
|
+
...(typeof body.axisKey === 'string' ? { axisKey: body.axisKey } : {}),
|
|
1564
|
+
...(Array.isArray(body.metrics)
|
|
1565
|
+
? { metrics: body.metrics.filter((m: unknown): m is string => typeof m === 'string') }
|
|
1566
|
+
: {}),
|
|
1567
|
+
...(Array.isArray(body.series)
|
|
1568
|
+
? { series: body.series.filter((s: unknown): s is string => typeof s === 'string') }
|
|
1569
|
+
: {}),
|
|
1570
|
+
...(typeof body.barKey === 'string' ? { barKey: body.barKey } : {}),
|
|
1571
|
+
...(typeof body.lineKey === 'string' ? { lineKey: body.lineKey } : {}),
|
|
1452
1572
|
...(aggregate ? { aggregate } : {}),
|
|
1453
1573
|
...(typeof body.color === 'string' ? { color: body.color } : {}),
|
|
1574
|
+
...(typeof body.barColor === 'string' ? { barColor: body.barColor } : {}),
|
|
1575
|
+
...(typeof body.lineColor === 'string' ? { lineColor: body.lineColor } : {}),
|
|
1454
1576
|
...(typeof body.height === 'number' ? { height: body.height } : {}),
|
|
1455
1577
|
},
|
|
1456
1578
|
}));
|
|
@@ -1773,7 +1895,7 @@ function handleRead(pathLike: string): Response {
|
|
|
1773
1895
|
}
|
|
1774
1896
|
|
|
1775
1897
|
function randomExtAppToolCallId(): string {
|
|
1776
|
-
return
|
|
1898
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1777
1899
|
}
|
|
1778
1900
|
|
|
1779
1901
|
function nodeAppSessionId(node: CanvasNodeState | undefined): string | null {
|
|
@@ -1853,10 +1975,15 @@ async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise
|
|
|
1853
1975
|
});
|
|
1854
1976
|
|
|
1855
1977
|
const toolCallId = randomExtAppToolCallId();
|
|
1978
|
+
const nodeIdSeed = toolCallId.startsWith('ext-app-') ? toolCallId : `ext-app-${toolCallId}`;
|
|
1979
|
+
const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
|
|
1980
|
+
? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
|
|
1981
|
+
: opened.toolResult;
|
|
1856
1982
|
const nodeTitle = params.title ?? opened.tool.title ?? opened.tool.name;
|
|
1857
1983
|
|
|
1858
1984
|
emitPrimaryWorkbenchEvent('ext-app-open', {
|
|
1859
1985
|
toolCallId,
|
|
1986
|
+
nodeId: nodeIdSeed,
|
|
1860
1987
|
title: nodeTitle,
|
|
1861
1988
|
html: opened.html,
|
|
1862
1989
|
toolInput: opened.toolInput,
|
|
@@ -1876,15 +2003,17 @@ async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise
|
|
|
1876
2003
|
});
|
|
1877
2004
|
emitPrimaryWorkbenchEvent('ext-app-result', {
|
|
1878
2005
|
toolCallId,
|
|
2006
|
+
nodeId: nodeIdSeed,
|
|
1879
2007
|
serverName: opened.serverName,
|
|
1880
2008
|
toolName: opened.toolName,
|
|
1881
|
-
success:
|
|
1882
|
-
result:
|
|
2009
|
+
success: toolResult.isError !== true,
|
|
2010
|
+
result: toolResult,
|
|
1883
2011
|
});
|
|
1884
2012
|
const nodeId = findCanvasExtAppNodeId(toolCallId);
|
|
1885
2013
|
|
|
1886
2014
|
return responseJson({
|
|
1887
2015
|
ok: true,
|
|
2016
|
+
...(nodeId ? { id: nodeId } : {}),
|
|
1888
2017
|
nodeId,
|
|
1889
2018
|
toolCallId,
|
|
1890
2019
|
sessionId: opened.sessionId,
|
|
@@ -1979,35 +2108,53 @@ async function handleExtAppCallTool(req: Request): Promise<Response> {
|
|
|
1979
2108
|
const nodeId = typeof body.nodeId === 'string' ? body.nodeId.trim() : '';
|
|
1980
2109
|
|
|
1981
2110
|
try {
|
|
1982
|
-
const
|
|
2111
|
+
const requestedNode = nodeId ? canvasState.getNode(nodeId) : undefined;
|
|
2112
|
+
const canReadLocalCheckpoint =
|
|
2113
|
+
requestedNode?.type === 'mcp-app' &&
|
|
2114
|
+
requestedNode.data.mode === 'ext-app' &&
|
|
2115
|
+
requestedNode.data.appSessionId === sessionId;
|
|
2116
|
+
const localCheckpointData = canReadLocalCheckpoint && toolName === EXCALIDRAW_READ_CHECKPOINT_TOOL
|
|
2117
|
+
? getLocalExcalidrawCheckpointData(requestedNode, args)
|
|
2118
|
+
: null;
|
|
2119
|
+
const result = localCheckpointData === null
|
|
2120
|
+
? await callMcpAppTool(sessionId, toolName, args)
|
|
2121
|
+
: { content: [{ type: 'text', text: localCheckpointData }] } satisfies CallToolResult;
|
|
1983
2122
|
if (nodeId) {
|
|
1984
2123
|
const node = canvasState.getNode(nodeId);
|
|
1985
2124
|
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
|
-
|
|
2125
|
+
let changed = false;
|
|
2126
|
+
if (toolName === EXCALIDRAW_SAVE_CHECKPOINT_TOOL && persistExcalidrawCheckpointToNode(nodeId, node, args)) {
|
|
2127
|
+
// Checkpoint saves are replayed through toolInput.elements instead of
|
|
2128
|
+
// replacing the original create_view result with a generic "ok".
|
|
2129
|
+
changed = true;
|
|
2130
|
+
} else if (!(isExcalidrawCreateView(node.data.serverName, node.data.toolName) && isCheckpointToolName(toolName))) {
|
|
2131
|
+
const nextData: Record<string, unknown> = { ...node.data };
|
|
2132
|
+
if (shouldReplayAppToolResult(toolName, result)) nextData.toolResult = result;
|
|
2133
|
+
const nextModelContext: Record<string, unknown> = {};
|
|
2134
|
+
if (Array.isArray(result.content)) {
|
|
2135
|
+
nextModelContext.content = result.content;
|
|
2136
|
+
}
|
|
2137
|
+
if (result.structuredContent && typeof result.structuredContent === 'object' && !Array.isArray(result.structuredContent)) {
|
|
2138
|
+
nextModelContext.structuredContent = result.structuredContent;
|
|
2139
|
+
}
|
|
2140
|
+
if (Object.keys(nextModelContext).length > 0) {
|
|
2141
|
+
nextData.appModelContext = {
|
|
2142
|
+
...nextModelContext,
|
|
2143
|
+
updatedAt: new Date().toISOString(),
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
2146
|
+
canvasState.updateNode(nodeId, {
|
|
2147
|
+
data: nextData,
|
|
2148
|
+
});
|
|
2149
|
+
changed = true;
|
|
1996
2150
|
}
|
|
1997
|
-
if (
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2151
|
+
if (changed) {
|
|
2152
|
+
broadcastWorkbenchEvent('canvas-layout-update', {
|
|
2153
|
+
layout: canvasState.getLayout(),
|
|
2154
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2155
|
+
timestamp: new Date().toISOString(),
|
|
2156
|
+
});
|
|
2002
2157
|
}
|
|
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
2158
|
}
|
|
2012
2159
|
}
|
|
2013
2160
|
return responseJson({ ok: true, result });
|
|
@@ -3031,10 +3178,13 @@ function syncEventToCanvasState(event: string, payload: PrimaryWorkbenchEventPay
|
|
|
3031
3178
|
} else if (event === 'ext-app-open') {
|
|
3032
3179
|
const toolCallId = payload.toolCallId as string;
|
|
3033
3180
|
if (!toolCallId) return;
|
|
3034
|
-
const id =
|
|
3181
|
+
const id = typeof payload.nodeId === 'string' && payload.nodeId.length > 0
|
|
3182
|
+
? payload.nodeId
|
|
3183
|
+
: toolCallId.startsWith('ext-app-') ? toolCallId : `ext-app-${toolCallId}`;
|
|
3035
3184
|
const dataPatch = {
|
|
3036
3185
|
mode: 'ext-app',
|
|
3037
3186
|
toolCallId,
|
|
3187
|
+
nodeId: id,
|
|
3038
3188
|
...(typeof payload.title === 'string' && payload.title.trim().length > 0
|
|
3039
3189
|
? { title: payload.title.trim() }
|
|
3040
3190
|
: {}),
|
|
@@ -3102,7 +3252,9 @@ function syncEventToCanvasState(event: string, payload: PrimaryWorkbenchEventPay
|
|
|
3102
3252
|
} else if (event === 'ext-app-update') {
|
|
3103
3253
|
const toolCallId = payload.toolCallId as string;
|
|
3104
3254
|
if (!toolCallId) return;
|
|
3255
|
+
const payloadNodeId = typeof payload.nodeId === 'string' ? payload.nodeId : '';
|
|
3105
3256
|
const id =
|
|
3257
|
+
(payloadNodeId && canvasState.getNode(payloadNodeId) ? payloadNodeId : null) ||
|
|
3106
3258
|
findCanvasExtAppNodeId(toolCallId) ||
|
|
3107
3259
|
(typeof payload.serverName === 'string' && typeof payload.toolName === 'string'
|
|
3108
3260
|
? findOnlyPendingCanvasExtAppNodeId(payload.serverName, payload.toolName)
|
|
@@ -3115,7 +3267,9 @@ function syncEventToCanvasState(event: string, payload: PrimaryWorkbenchEventPay
|
|
|
3115
3267
|
} else if (event === 'ext-app-result') {
|
|
3116
3268
|
const toolCallId = payload.toolCallId as string;
|
|
3117
3269
|
if (!toolCallId) return;
|
|
3270
|
+
const payloadNodeId = typeof payload.nodeId === 'string' ? payload.nodeId : '';
|
|
3118
3271
|
const id =
|
|
3272
|
+
(payloadNodeId && canvasState.getNode(payloadNodeId) ? payloadNodeId : null) ||
|
|
3119
3273
|
findCanvasExtAppNodeId(toolCallId) ||
|
|
3120
3274
|
(typeof payload.serverName === 'string' && typeof payload.toolName === 'string'
|
|
3121
3275
|
? findOnlyPendingCanvasExtAppNodeId(payload.serverName, payload.toolName)
|
|
@@ -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
|
},
|
|
@@ -452,6 +494,7 @@ export function openWebArtifactInCanvas(input: {
|
|
|
452
494
|
trustedDomain: true,
|
|
453
495
|
sourceServer: 'pmx-canvas',
|
|
454
496
|
hostMode: 'hosted',
|
|
497
|
+
viewerType: 'web-artifact',
|
|
455
498
|
},
|
|
456
499
|
};
|
|
457
500
|
|