pmx-canvas 0.1.23 → 0.1.24
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/.github/extensions/pmx-canvas/extension.mjs +591 -0
- package/CHANGELOG.md +70 -0
- package/Readme.md +36 -5
- package/dist/canvas/global.css +36 -3
- package/dist/canvas/index.js +54 -54
- package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -0
- package/dist/types/client/nodes/iframe-document-url.d.ts +8 -0
- package/dist/types/client/state/intent-bridge.d.ts +4 -0
- package/dist/types/client/types.d.ts +1 -0
- package/dist/types/json-render/catalog.d.ts +1 -1
- package/dist/types/mcp/canvas-access.d.ts +9 -0
- package/dist/types/server/ax-context.d.ts +3 -0
- package/dist/types/server/ax-state.d.ts +43 -0
- package/dist/types/server/canvas-db.d.ts +5 -0
- package/dist/types/server/canvas-state.d.ts +19 -3
- package/dist/types/server/index.d.ts +6 -0
- package/dist/types/server/mutation-history.d.ts +1 -1
- package/docs/cli.md +13 -0
- package/docs/http-api.md +24 -0
- package/docs/mcp.md +20 -2
- package/docs/plans/plan-004-pmx-ax-primitives.md +463 -0
- package/docs/screenshot.png +0 -0
- package/docs/sdk.md +5 -0
- package/package.json +2 -1
- package/skills/pmx-canvas/SKILL.md +14 -0
- package/skills/pmx-canvas/references/codex-app-adapter.md +107 -0
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +111 -0
- package/src/cli/agent.ts +34 -0
- package/src/cli/index.ts +2 -1
- package/src/client/App.tsx +2 -0
- package/src/client/canvas/CanvasNode.tsx +7 -0
- package/src/client/canvas/CommandPalette.tsx +2 -1
- package/src/client/canvas/use-node-drag.ts +29 -7
- package/src/client/canvas/use-node-resize.ts +27 -7
- package/src/client/nodes/ExtAppFrame.tsx +51 -10
- package/src/client/nodes/HtmlNode.tsx +5 -2
- package/src/client/nodes/iframe-document-url.ts +58 -0
- package/src/client/state/intent-bridge.ts +8 -0
- package/src/client/state/sse-bridge.ts +2 -2
- package/src/client/theme/global.css +36 -3
- package/src/client/types.ts +1 -0
- package/src/mcp/canvas-access.ts +38 -0
- package/src/mcp/server.ts +113 -4
- package/src/server/ax-context.ts +38 -0
- package/src/server/ax-state.ts +130 -0
- package/src/server/canvas-db.ts +36 -1
- package/src/server/canvas-operations.ts +79 -0
- package/src/server/canvas-state.ts +113 -2
- package/src/server/index.ts +18 -0
- package/src/server/mutation-history.ts +1 -0
- package/src/server/server.ts +193 -8
|
@@ -30,7 +30,9 @@ import {
|
|
|
30
30
|
checkpointCanvasDb,
|
|
31
31
|
finalizeCanvasDbForClose,
|
|
32
32
|
type PersistedCanvasState,
|
|
33
|
+
type CanvasTheme,
|
|
33
34
|
} from './canvas-db.js';
|
|
35
|
+
import { normalizeCanvasTheme } from './canvas-db.js';
|
|
34
36
|
import {
|
|
35
37
|
type CanvasPlacementRect,
|
|
36
38
|
computeGroupBounds,
|
|
@@ -39,6 +41,13 @@ import {
|
|
|
39
41
|
GROUP_TITLEBAR_HEIGHT,
|
|
40
42
|
resolveGroupCollision,
|
|
41
43
|
} from './placement.js';
|
|
44
|
+
import {
|
|
45
|
+
createEmptyAxState,
|
|
46
|
+
normalizeAxState,
|
|
47
|
+
type PmxAxFocusState,
|
|
48
|
+
type PmxAxSource,
|
|
49
|
+
type PmxAxState,
|
|
50
|
+
} from './ax-state.js';
|
|
42
51
|
|
|
43
52
|
function logCanvasStateWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
|
|
44
53
|
console.warn(`[canvas-state] ${action}`, { error, ...(details ?? {}) });
|
|
@@ -186,6 +195,7 @@ export interface CanvasAnnotation {
|
|
|
186
195
|
|
|
187
196
|
export interface CanvasLayout {
|
|
188
197
|
viewport: ViewportState;
|
|
198
|
+
theme: CanvasTheme;
|
|
189
199
|
nodes: CanvasNodeState[];
|
|
190
200
|
edges: CanvasEdge[];
|
|
191
201
|
annotations: CanvasAnnotation[];
|
|
@@ -199,10 +209,10 @@ export interface CanvasNodeUpdate {
|
|
|
199
209
|
dockPosition?: 'left' | 'right' | null;
|
|
200
210
|
}
|
|
201
211
|
|
|
202
|
-
export type CanvasChangeType = 'pins' | 'nodes';
|
|
212
|
+
export type CanvasChangeType = 'pins' | 'nodes' | 'ax';
|
|
203
213
|
|
|
204
214
|
export interface MutationRecordInfo {
|
|
205
|
-
operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'restoreSnapshot' | 'setPins' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
|
|
215
|
+
operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'restoreSnapshot' | 'setPins' | 'setAxFocus' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
|
|
206
216
|
description: string;
|
|
207
217
|
forward: () => void;
|
|
208
218
|
inverse: () => void;
|
|
@@ -260,7 +270,9 @@ class CanvasStateManager {
|
|
|
260
270
|
private edges = new Map<string, CanvasEdge>();
|
|
261
271
|
private annotations = new Map<string, CanvasAnnotation>();
|
|
262
272
|
private _viewport: ViewportState = { x: 0, y: 0, scale: 1 };
|
|
273
|
+
private _theme: CanvasTheme = 'dark';
|
|
263
274
|
private _contextPinnedNodeIds = new Set<string>();
|
|
275
|
+
private _axState: PmxAxState = createEmptyAxState();
|
|
264
276
|
private _workspaceRoot = process.cwd();
|
|
265
277
|
|
|
266
278
|
// ── Change listeners (for MCP resource notifications) ──────
|
|
@@ -310,6 +322,18 @@ class CanvasStateManager {
|
|
|
310
322
|
}
|
|
311
323
|
}
|
|
312
324
|
|
|
325
|
+
private currentNodeIdSet(): Set<string> {
|
|
326
|
+
return new Set(this.nodes.keys());
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private normalizeAxForCurrentNodes(state: unknown): PmxAxState {
|
|
330
|
+
return normalizeAxState(state, this.currentNodeIdSet());
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private applyAxState(state: PmxAxState): void {
|
|
334
|
+
this._axState = this.normalizeAxForCurrentNodes(state);
|
|
335
|
+
}
|
|
336
|
+
|
|
313
337
|
private applyResolvedGroupBounds(
|
|
314
338
|
group: CanvasNodeState,
|
|
315
339
|
groupId: string,
|
|
@@ -788,11 +812,13 @@ class CanvasStateManager {
|
|
|
788
812
|
private emptyPersistedState(): PersistedCanvasState {
|
|
789
813
|
return {
|
|
790
814
|
version: 1,
|
|
815
|
+
theme: this._theme,
|
|
791
816
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
792
817
|
nodes: [],
|
|
793
818
|
edges: [],
|
|
794
819
|
annotations: [],
|
|
795
820
|
contextPins: [],
|
|
821
|
+
ax: createEmptyAxState(),
|
|
796
822
|
};
|
|
797
823
|
}
|
|
798
824
|
|
|
@@ -863,11 +889,13 @@ class CanvasStateManager {
|
|
|
863
889
|
try {
|
|
864
890
|
const payload = this.externalizePersistedStateBlobs({
|
|
865
891
|
version: 1,
|
|
892
|
+
theme: this._theme,
|
|
866
893
|
viewport: this._viewport,
|
|
867
894
|
nodes: Array.from(this.nodes.values()),
|
|
868
895
|
edges: Array.from(this.edges.values()),
|
|
869
896
|
annotations: Array.from(this.annotations.values()),
|
|
870
897
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
898
|
+
ax: this.getAxState(),
|
|
871
899
|
});
|
|
872
900
|
saveStateToDB(this._db, payload);
|
|
873
901
|
} catch (error) {
|
|
@@ -909,12 +937,14 @@ class CanvasStateManager {
|
|
|
909
937
|
this.edges.clear();
|
|
910
938
|
this.annotations.clear();
|
|
911
939
|
this._contextPinnedNodeIds.clear();
|
|
940
|
+
this._axState = createEmptyAxState();
|
|
912
941
|
|
|
913
942
|
this._viewport = {
|
|
914
943
|
x: state.viewport?.x ?? 0,
|
|
915
944
|
y: state.viewport?.y ?? 0,
|
|
916
945
|
scale: state.viewport?.scale ?? 1,
|
|
917
946
|
};
|
|
947
|
+
this._theme = normalizeCanvasTheme(state.theme, this._theme);
|
|
918
948
|
|
|
919
949
|
if (Array.isArray(state.nodes)) {
|
|
920
950
|
for (const node of state.nodes) {
|
|
@@ -938,6 +968,7 @@ class CanvasStateManager {
|
|
|
938
968
|
if (this.nodes.has(pinId)) this._contextPinnedNodeIds.add(pinId);
|
|
939
969
|
}
|
|
940
970
|
}
|
|
971
|
+
this._axState = this.normalizeAxForCurrentNodes(state.ax);
|
|
941
972
|
}
|
|
942
973
|
|
|
943
974
|
private readResolvedSnapshot(idOrName: string): {
|
|
@@ -1028,11 +1059,13 @@ class CanvasStateManager {
|
|
|
1028
1059
|
try {
|
|
1029
1060
|
const payload = this.externalizePersistedStateBlobs({
|
|
1030
1061
|
version: 1,
|
|
1062
|
+
theme: this._theme,
|
|
1031
1063
|
viewport: this._viewport,
|
|
1032
1064
|
nodes: Array.from(this.nodes.values()),
|
|
1033
1065
|
edges: Array.from(this.edges.values()),
|
|
1034
1066
|
annotations: Array.from(this.annotations.values()),
|
|
1035
1067
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
1068
|
+
ax: this.getAxState(),
|
|
1036
1069
|
});
|
|
1037
1070
|
saveSnapshotToDB(this._db, snapshot, payload);
|
|
1038
1071
|
snapshot.nodeCount = payload.nodes.length;
|
|
@@ -1117,19 +1150,23 @@ class CanvasStateManager {
|
|
|
1117
1150
|
|
|
1118
1151
|
const previousState: PersistedCanvasState = this.externalizePersistedStateBlobs({
|
|
1119
1152
|
version: 1,
|
|
1153
|
+
theme: this._theme,
|
|
1120
1154
|
viewport: structuredClone(this._viewport),
|
|
1121
1155
|
nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
|
|
1122
1156
|
edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
|
|
1123
1157
|
annotations: Array.from(this.annotations.values(), (annotation) => structuredClone(annotation)),
|
|
1124
1158
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
1159
|
+
ax: this.getAxState(),
|
|
1125
1160
|
});
|
|
1126
1161
|
const nextState: PersistedCanvasState = {
|
|
1127
1162
|
version: 1,
|
|
1163
|
+
theme: normalizeCanvasTheme(resolved.state.theme, this._theme),
|
|
1128
1164
|
viewport: structuredClone(resolved.state.viewport),
|
|
1129
1165
|
nodes: Array.isArray(resolved.state.nodes) ? resolved.state.nodes.map((node) => structuredClone(node)) : [],
|
|
1130
1166
|
edges: Array.isArray(resolved.state.edges) ? resolved.state.edges.map((edge) => structuredClone(edge)) : [],
|
|
1131
1167
|
annotations: Array.isArray(resolved.state.annotations) ? resolved.state.annotations.map((annotation) => structuredClone(annotation)) : [],
|
|
1132
1168
|
contextPins: Array.isArray(resolved.state.contextPins) ? [...resolved.state.contextPins] : [],
|
|
1169
|
+
ax: resolved.state.ax ? structuredClone(resolved.state.ax) : createEmptyAxState(),
|
|
1133
1170
|
};
|
|
1134
1171
|
|
|
1135
1172
|
try {
|
|
@@ -1137,6 +1174,7 @@ class CanvasStateManager {
|
|
|
1137
1174
|
this.scheduleSave();
|
|
1138
1175
|
this.notifyChange('nodes');
|
|
1139
1176
|
this.notifyChange('pins');
|
|
1177
|
+
this.notifyChange('ax');
|
|
1140
1178
|
this.recordMutation({
|
|
1141
1179
|
operationType: 'restoreSnapshot',
|
|
1142
1180
|
description: `Restored snapshot "${resolved.snapshot.name}"`,
|
|
@@ -1145,12 +1183,14 @@ class CanvasStateManager {
|
|
|
1145
1183
|
this.scheduleSave();
|
|
1146
1184
|
this.notifyChange('nodes');
|
|
1147
1185
|
this.notifyChange('pins');
|
|
1186
|
+
this.notifyChange('ax');
|
|
1148
1187
|
}),
|
|
1149
1188
|
inverse: this.suppressed(() => {
|
|
1150
1189
|
this.applyPersistedState(previousState);
|
|
1151
1190
|
this.scheduleSave();
|
|
1152
1191
|
this.notifyChange('nodes');
|
|
1153
1192
|
this.notifyChange('pins');
|
|
1193
|
+
this.notifyChange('ax');
|
|
1154
1194
|
}),
|
|
1155
1195
|
});
|
|
1156
1196
|
return true;
|
|
@@ -1287,6 +1327,7 @@ class CanvasStateManager {
|
|
|
1287
1327
|
const existing = this.nodes.get(id);
|
|
1288
1328
|
const connectedEdges = existing ? this.getEdgesForNode(id).map((e) => structuredClone(e)) : [];
|
|
1289
1329
|
const cloned = existing ? structuredClone(existing) : null;
|
|
1330
|
+
const oldAxState = this.getAxState();
|
|
1290
1331
|
|
|
1291
1332
|
// Prune from parent group's children list
|
|
1292
1333
|
if (existing) {
|
|
@@ -1315,9 +1356,11 @@ class CanvasStateManager {
|
|
|
1315
1356
|
this.nodes.delete(id);
|
|
1316
1357
|
this.removeEdgesForNode(id);
|
|
1317
1358
|
this._contextPinnedNodeIds.delete(id);
|
|
1359
|
+
this.applyAxState(this._axState);
|
|
1318
1360
|
this.scheduleSave();
|
|
1319
1361
|
this.notifyChange('nodes');
|
|
1320
1362
|
this.notifyChange('pins');
|
|
1363
|
+
this.notifyChange('ax');
|
|
1321
1364
|
if (cloned) {
|
|
1322
1365
|
this.recordMutation({
|
|
1323
1366
|
operationType: 'removeNode',
|
|
@@ -1326,6 +1369,9 @@ class CanvasStateManager {
|
|
|
1326
1369
|
inverse: this.suppressed(() => {
|
|
1327
1370
|
this.addNode(structuredClone(cloned));
|
|
1328
1371
|
for (const edge of connectedEdges) this.addEdge(structuredClone(edge));
|
|
1372
|
+
this.applyAxState(oldAxState);
|
|
1373
|
+
this.scheduleSave();
|
|
1374
|
+
this.notifyChange('ax');
|
|
1329
1375
|
}),
|
|
1330
1376
|
});
|
|
1331
1377
|
}
|
|
@@ -1435,6 +1481,7 @@ class CanvasStateManager {
|
|
|
1435
1481
|
getLayout(): CanvasLayout {
|
|
1436
1482
|
return {
|
|
1437
1483
|
viewport: structuredClone(this._viewport),
|
|
1484
|
+
theme: this._theme,
|
|
1438
1485
|
nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
|
|
1439
1486
|
edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
|
|
1440
1487
|
annotations: this.getAnnotations(),
|
|
@@ -1444,6 +1491,7 @@ class CanvasStateManager {
|
|
|
1444
1491
|
getLayoutForPersistence(): CanvasLayout {
|
|
1445
1492
|
return {
|
|
1446
1493
|
viewport: structuredClone(this._viewport),
|
|
1494
|
+
theme: this._theme,
|
|
1447
1495
|
nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.externalizeNodeDataBlobs(node))),
|
|
1448
1496
|
edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
|
|
1449
1497
|
annotations: this.getAnnotations(),
|
|
@@ -1563,12 +1611,70 @@ class CanvasStateManager {
|
|
|
1563
1611
|
});
|
|
1564
1612
|
}
|
|
1565
1613
|
|
|
1614
|
+
get theme(): CanvasTheme {
|
|
1615
|
+
return this._theme;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
setTheme(theme: CanvasTheme): CanvasTheme {
|
|
1619
|
+
const next = normalizeCanvasTheme(theme, this._theme);
|
|
1620
|
+
if (next === this._theme) return this._theme;
|
|
1621
|
+
this._theme = next;
|
|
1622
|
+
this.scheduleSave();
|
|
1623
|
+
this.notifyChange('nodes');
|
|
1624
|
+
return this._theme;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1566
1627
|
// ── Context pins ─────────────────────────────────────────────
|
|
1567
1628
|
|
|
1568
1629
|
get contextPinnedNodeIds(): Set<string> {
|
|
1569
1630
|
return new Set(this._contextPinnedNodeIds);
|
|
1570
1631
|
}
|
|
1571
1632
|
|
|
1633
|
+
getAxState(): PmxAxState {
|
|
1634
|
+
return structuredClone(this.normalizeAxForCurrentNodes(this._axState));
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
getAxFocus(): PmxAxFocusState {
|
|
1638
|
+
return this.getAxState().focus;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
setAxFocus(nodeIds: string[], options: { source?: PmxAxSource; recordHistory?: boolean } = {}): PmxAxFocusState {
|
|
1642
|
+
const oldAxState = this.getAxState();
|
|
1643
|
+
const nextAxState: PmxAxState = {
|
|
1644
|
+
...oldAxState,
|
|
1645
|
+
focus: {
|
|
1646
|
+
nodeIds,
|
|
1647
|
+
primaryNodeId: nodeIds[0] ?? null,
|
|
1648
|
+
updatedAt: new Date().toISOString(),
|
|
1649
|
+
source: options.source ?? 'api',
|
|
1650
|
+
},
|
|
1651
|
+
};
|
|
1652
|
+
this.applyAxState(nextAxState);
|
|
1653
|
+
const appliedAxState = this.getAxState();
|
|
1654
|
+
this.scheduleSave();
|
|
1655
|
+
this.notifyChange('ax');
|
|
1656
|
+
if (options.recordHistory === false) return appliedAxState.focus;
|
|
1657
|
+
this.recordMutation({
|
|
1658
|
+
operationType: 'setAxFocus',
|
|
1659
|
+
description: `Set AX focus (${appliedAxState.focus.nodeIds.length} nodes)`,
|
|
1660
|
+
forward: this.suppressed(() => {
|
|
1661
|
+
this.applyAxState(appliedAxState);
|
|
1662
|
+
this.scheduleSave();
|
|
1663
|
+
this.notifyChange('ax');
|
|
1664
|
+
}),
|
|
1665
|
+
inverse: this.suppressed(() => {
|
|
1666
|
+
this.applyAxState(oldAxState);
|
|
1667
|
+
this.scheduleSave();
|
|
1668
|
+
this.notifyChange('ax');
|
|
1669
|
+
}),
|
|
1670
|
+
});
|
|
1671
|
+
return appliedAxState.focus;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
clearAxFocus(): PmxAxFocusState {
|
|
1675
|
+
return this.setAxFocus([], { source: 'system' });
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1572
1678
|
setContextPins(nodeIds: string[]): void {
|
|
1573
1679
|
const oldPins = Array.from(this._contextPinnedNodeIds);
|
|
1574
1680
|
this._contextPinnedNodeIds.clear();
|
|
@@ -1694,15 +1800,18 @@ class CanvasStateManager {
|
|
|
1694
1800
|
const oldEdges = Array.from(this.edges.values()).map((e) => structuredClone(e));
|
|
1695
1801
|
const oldAnnotations = Array.from(this.annotations.values()).map((annotation) => structuredClone(annotation));
|
|
1696
1802
|
const oldPins = Array.from(this._contextPinnedNodeIds);
|
|
1803
|
+
const oldAxState = this.getAxState();
|
|
1697
1804
|
const oldViewport = { ...this._viewport };
|
|
1698
1805
|
this.nodes.clear();
|
|
1699
1806
|
this.edges.clear();
|
|
1700
1807
|
this.annotations.clear();
|
|
1701
1808
|
this._contextPinnedNodeIds.clear();
|
|
1809
|
+
this._axState = createEmptyAxState();
|
|
1702
1810
|
this._viewport = { x: 0, y: 0, scale: 1 };
|
|
1703
1811
|
this.scheduleSave();
|
|
1704
1812
|
this.notifyChange('nodes');
|
|
1705
1813
|
this.notifyChange('pins');
|
|
1814
|
+
this.notifyChange('ax');
|
|
1706
1815
|
this.recordMutation({
|
|
1707
1816
|
operationType: 'clear',
|
|
1708
1817
|
description: `Cleared canvas (was ${oldNodes.length} nodes, ${oldEdges.length} edges)`,
|
|
@@ -1712,7 +1821,9 @@ class CanvasStateManager {
|
|
|
1712
1821
|
for (const e of oldEdges) this.addEdge(structuredClone(e));
|
|
1713
1822
|
for (const annotation of oldAnnotations) this.addAnnotation(structuredClone(annotation));
|
|
1714
1823
|
this.setContextPins(oldPins);
|
|
1824
|
+
this.applyAxState(oldAxState);
|
|
1715
1825
|
this.setViewport(oldViewport);
|
|
1826
|
+
this.notifyChange('ax');
|
|
1716
1827
|
}),
|
|
1717
1828
|
});
|
|
1718
1829
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
import { canvasState, IMAGE_MIME_MAP } from './canvas-state.js';
|
|
3
3
|
import type { CanvasAnnotation, CanvasNodeState, CanvasEdge, CanvasLayout, ViewportState } from './canvas-state.js';
|
|
4
|
+
import { buildCanvasAxContext } from './ax-context.js';
|
|
5
|
+
import type { PmxAxContext, PmxAxFocusState, PmxAxSource, PmxAxState } from './ax-state.js';
|
|
4
6
|
import { findCanvasExtAppNodeId } from './ext-app-lookup.js';
|
|
5
7
|
import { onFileNodeChanged } from './file-watcher.js';
|
|
6
8
|
import { findOpenCanvasPosition, computeGroupBounds } from './placement.js';
|
|
@@ -402,7 +404,9 @@ export class PmxCanvas extends EventEmitter {
|
|
|
402
404
|
y: node.position.y - 100,
|
|
403
405
|
});
|
|
404
406
|
}
|
|
407
|
+
const focus = canvasState.setAxFocus([id], { source: 'sdk', recordHistory: false });
|
|
405
408
|
emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId: id, noPan });
|
|
409
|
+
emitPrimaryWorkbenchEvent('ax-state-changed', { focus });
|
|
406
410
|
if (!noPan) {
|
|
407
411
|
emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
|
|
408
412
|
}
|
|
@@ -410,6 +414,20 @@ export class PmxCanvas extends EventEmitter {
|
|
|
410
414
|
return { focused: id, panned: !noPan };
|
|
411
415
|
}
|
|
412
416
|
|
|
417
|
+
getAxState(): PmxAxState {
|
|
418
|
+
return canvasState.getAxState();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
getAxContext(): PmxAxContext {
|
|
422
|
+
return buildCanvasAxContext();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
setAxFocus(nodeIds: string[], options?: { source?: PmxAxSource }): PmxAxFocusState {
|
|
426
|
+
const focus = canvasState.setAxFocus(nodeIds, { source: options?.source ?? 'sdk' });
|
|
427
|
+
emitPrimaryWorkbenchEvent('ax-state-changed', { focus });
|
|
428
|
+
return focus;
|
|
429
|
+
}
|
|
430
|
+
|
|
413
431
|
fitView(options?: {
|
|
414
432
|
width?: number;
|
|
415
433
|
height?: number;
|
package/src/server/server.ts
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import { spawnSync } from 'node:child_process';
|
|
38
|
+
import { randomUUID } from 'node:crypto';
|
|
38
39
|
import { existsSync, readFileSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
39
40
|
import { readFile } from 'node:fs/promises';
|
|
40
41
|
import { basename, extname, join, relative, resolve } from 'node:path';
|
|
@@ -75,6 +76,9 @@ import {
|
|
|
75
76
|
} from './canvas-serialization.js';
|
|
76
77
|
import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
|
|
77
78
|
import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
|
|
79
|
+
import { buildCanvasAxContext } from './ax-context.js';
|
|
80
|
+
import type { PmxAxSource } from './ax-state.js';
|
|
81
|
+
import { normalizeCanvasTheme, type CanvasTheme } from './canvas-db.js';
|
|
78
82
|
import { validateLocalImageFile } from './image-source.js';
|
|
79
83
|
import {
|
|
80
84
|
addCanvasNode,
|
|
@@ -145,9 +149,7 @@ let nextWorkbenchSubscriberId = 1;
|
|
|
145
149
|
const workbenchSubscribers = new Map<number, ReadableStreamDefaultController<Uint8Array>>();
|
|
146
150
|
const textEncoder = new TextEncoder();
|
|
147
151
|
let primaryWorkbenchAutoOpenEnabled = true;
|
|
148
|
-
const
|
|
149
|
-
? process.env.PMX_CANVAS_THEME!
|
|
150
|
-
: 'dark');
|
|
152
|
+
const initialCanvasThemeSetting = normalizeCanvasTheme(process.env.PMX_CANVAS_THEME);
|
|
151
153
|
let lastWorkbenchContextCardsEnvelope: Record<string, unknown> | null = null;
|
|
152
154
|
|
|
153
155
|
function normalizeGraphViewerSpec(
|
|
@@ -1038,6 +1040,24 @@ function normalizeMarkdownExternalUrls(markdown: string): string {
|
|
|
1038
1040
|
|
|
1039
1041
|
// ── Canvas SPA HTML ────────────────────────────────────────────
|
|
1040
1042
|
|
|
1043
|
+
const CANVAS_ASSET_VERSION = Date.now().toString(36);
|
|
1044
|
+
const MAX_FRAME_DOCUMENTS = 128;
|
|
1045
|
+
const MAX_FRAME_DOCUMENT_BYTES = 5 * 1024 * 1024;
|
|
1046
|
+
const DEFAULT_FRAME_DOCUMENT_SANDBOX = 'allow-scripts';
|
|
1047
|
+
const SAFE_FRAME_DOCUMENT_SANDBOX_TOKENS = new Set([
|
|
1048
|
+
'allow-downloads',
|
|
1049
|
+
'allow-forms',
|
|
1050
|
+
'allow-modals',
|
|
1051
|
+
'allow-orientation-lock',
|
|
1052
|
+
'allow-pointer-lock',
|
|
1053
|
+
'allow-popups',
|
|
1054
|
+
'allow-popups-to-escape-sandbox',
|
|
1055
|
+
'allow-presentation',
|
|
1056
|
+
'allow-scripts',
|
|
1057
|
+
'allow-storage-access-by-user-activation',
|
|
1058
|
+
]);
|
|
1059
|
+
const frameDocuments = new Map<string, { html: string; sandbox: string }>();
|
|
1060
|
+
|
|
1041
1061
|
function canvasSpaHtml(): string {
|
|
1042
1062
|
return `<!doctype html>
|
|
1043
1063
|
<html lang="en">
|
|
@@ -1106,7 +1126,7 @@ function canvasSpaHtml(): string {
|
|
|
1106
1126
|
color: #eef4ff;
|
|
1107
1127
|
}
|
|
1108
1128
|
</style>
|
|
1109
|
-
<link rel="stylesheet" href="/canvas/global.css" />
|
|
1129
|
+
<link rel="stylesheet" href="/canvas/global.css?v=${CANVAS_ASSET_VERSION}" />
|
|
1110
1130
|
</head>
|
|
1111
1131
|
<body>
|
|
1112
1132
|
<div id="canvasBootstrap">
|
|
@@ -1143,7 +1163,7 @@ function canvasSpaHtml(): string {
|
|
|
1143
1163
|
}, 4000);
|
|
1144
1164
|
})();
|
|
1145
1165
|
</script>
|
|
1146
|
-
<script type="module" src="/canvas/index.js"></script>
|
|
1166
|
+
<script type="module" src="/canvas/index.js?v=${CANVAS_ASSET_VERSION}"></script>
|
|
1147
1167
|
</body>
|
|
1148
1168
|
</html>`;
|
|
1149
1169
|
}
|
|
@@ -1229,6 +1249,61 @@ function serveCanvasFavicon(): Response {
|
|
|
1229
1249
|
|
|
1230
1250
|
// ── Canvas REST handlers ──────────────────────────────────────
|
|
1231
1251
|
|
|
1252
|
+
function normalizeFrameDocumentSandbox(value: unknown): string | null {
|
|
1253
|
+
if (value === undefined || value === null) return DEFAULT_FRAME_DOCUMENT_SANDBOX;
|
|
1254
|
+
if (typeof value !== 'string') return null;
|
|
1255
|
+
const tokens = value.trim().split(/\s+/).filter(Boolean);
|
|
1256
|
+
if (tokens.length === 0) return DEFAULT_FRAME_DOCUMENT_SANDBOX;
|
|
1257
|
+
const uniqueTokens: string[] = [];
|
|
1258
|
+
for (const token of tokens) {
|
|
1259
|
+
if (!SAFE_FRAME_DOCUMENT_SANDBOX_TOKENS.has(token)) return null;
|
|
1260
|
+
if (!uniqueTokens.includes(token)) uniqueTokens.push(token);
|
|
1261
|
+
}
|
|
1262
|
+
return uniqueTokens.join(' ');
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function addFrameDocument(html: string, sandbox: string): string {
|
|
1266
|
+
const id = randomUUID();
|
|
1267
|
+
frameDocuments.set(id, { html, sandbox });
|
|
1268
|
+
while (frameDocuments.size > MAX_FRAME_DOCUMENTS) {
|
|
1269
|
+
const firstKey = frameDocuments.keys().next().value;
|
|
1270
|
+
if (typeof firstKey !== 'string') break;
|
|
1271
|
+
frameDocuments.delete(firstKey);
|
|
1272
|
+
}
|
|
1273
|
+
return `/api/canvas/frame-documents/${id}`;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
async function handleCreateFrameDocument(req: Request): Promise<Response> {
|
|
1277
|
+
const body = await readJson(req);
|
|
1278
|
+
const html = body.html;
|
|
1279
|
+
if (typeof html !== 'string' || !html) {
|
|
1280
|
+
return responseJson({ ok: false, error: 'Frame document requires non-empty html.' }, 400);
|
|
1281
|
+
}
|
|
1282
|
+
if (new TextEncoder().encode(html).byteLength > MAX_FRAME_DOCUMENT_BYTES) {
|
|
1283
|
+
return responseJson({ ok: false, error: 'Frame document is too large.' }, 413);
|
|
1284
|
+
}
|
|
1285
|
+
const sandbox = normalizeFrameDocumentSandbox(body.sandbox);
|
|
1286
|
+
if (!sandbox) {
|
|
1287
|
+
return responseJson({ ok: false, error: 'Frame document sandbox contains unsupported tokens.' }, 400);
|
|
1288
|
+
}
|
|
1289
|
+
return responseJson({ ok: true, url: addFrameDocument(html, sandbox) });
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function handleFrameDocument(pathname: string): Response {
|
|
1293
|
+
const id = decodeURIComponent(pathname.slice('/api/canvas/frame-documents/'.length));
|
|
1294
|
+
const document = frameDocuments.get(id);
|
|
1295
|
+
if (!document) return responseText('Frame document not found.', 404);
|
|
1296
|
+
return new Response(document.html, {
|
|
1297
|
+
headers: {
|
|
1298
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1299
|
+
'Cache-Control': 'no-store',
|
|
1300
|
+
'Content-Security-Policy': `sandbox ${document.sandbox}`,
|
|
1301
|
+
'Referrer-Policy': 'no-referrer',
|
|
1302
|
+
'X-Content-Type-Options': 'nosniff',
|
|
1303
|
+
},
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1232
1307
|
async function handleCanvasUpdate(req: Request): Promise<Response> {
|
|
1233
1308
|
const body = await readJson(req);
|
|
1234
1309
|
const updates = Array.isArray(body.updates) ? body.updates : [];
|
|
@@ -1817,10 +1892,16 @@ async function handleCanvasFocus(req: Request): Promise<Response> {
|
|
|
1817
1892
|
const maxZ = canvasState.getLayout().nodes.reduce((max, layoutNode) => Math.max(max, layoutNode.zIndex), 0);
|
|
1818
1893
|
canvasState.updateNode(nodeId, { zIndex: maxZ + 1 });
|
|
1819
1894
|
}
|
|
1895
|
+
const focus = canvasState.setAxFocus([nodeId], { source: 'api', recordHistory: false });
|
|
1896
|
+
broadcastWorkbenchEvent('ax-state-changed', {
|
|
1897
|
+
focus,
|
|
1898
|
+
sessionId: primaryWorkbenchSessionId,
|
|
1899
|
+
timestamp: new Date().toISOString(),
|
|
1900
|
+
});
|
|
1820
1901
|
emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId, noPan });
|
|
1821
1902
|
if (!noPan) emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
|
|
1822
1903
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1823
|
-
return responseJson({ ok: true, focused: nodeId, panned: !noPan });
|
|
1904
|
+
return responseJson({ ok: true, focused: nodeId, panned: !noPan, axFocus: focus });
|
|
1824
1905
|
}
|
|
1825
1906
|
|
|
1826
1907
|
async function handleCanvasFit(req: Request): Promise<Response> {
|
|
@@ -2105,6 +2186,18 @@ function handleCanvasValidate(): Response {
|
|
|
2105
2186
|
return responseJson(validateCanvasLayout(canvasState.getLayout()));
|
|
2106
2187
|
}
|
|
2107
2188
|
|
|
2189
|
+
async function handleCanvasThemeUpdate(req: Request): Promise<Response> {
|
|
2190
|
+
const body = await readJson(req);
|
|
2191
|
+
const theme = normalizeCanvasTheme(body.theme, canvasState.theme);
|
|
2192
|
+
const next = canvasState.setTheme(theme);
|
|
2193
|
+
broadcastWorkbenchEvent('theme-changed', {
|
|
2194
|
+
theme: next,
|
|
2195
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2196
|
+
timestamp: new Date().toISOString(),
|
|
2197
|
+
});
|
|
2198
|
+
return responseJson({ ok: true, theme: next });
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2108
2201
|
async function handleJsonRenderView(url: URL): Promise<Response> {
|
|
2109
2202
|
const nodeId = url.searchParams.get('nodeId') ?? '';
|
|
2110
2203
|
if (!nodeId) return responseText('Missing nodeId', 400);
|
|
@@ -2973,7 +3066,7 @@ function handleWorkbenchEvents(req: Request): Response {
|
|
|
2973
3066
|
requestedSessionId: requestedSessionId || null,
|
|
2974
3067
|
continuity,
|
|
2975
3068
|
path: primaryWorkbenchPath,
|
|
2976
|
-
theme:
|
|
3069
|
+
theme: canvasState.theme,
|
|
2977
3070
|
timestamp: new Date().toISOString(),
|
|
2978
3071
|
}),
|
|
2979
3072
|
);
|
|
@@ -3391,6 +3484,63 @@ function handleGetPinnedContext(): Response {
|
|
|
3391
3484
|
return responseJson({ preamble, nodeIds: pinnedIds, count: pinnedIds.length, nodes });
|
|
3392
3485
|
}
|
|
3393
3486
|
|
|
3487
|
+
function normalizeAxNodeIds(value: unknown): string[] {
|
|
3488
|
+
if (!Array.isArray(value)) return [];
|
|
3489
|
+
return value.filter((id): id is string => typeof id === 'string');
|
|
3490
|
+
}
|
|
3491
|
+
|
|
3492
|
+
function normalizeAxSource(value: unknown, fallback: PmxAxSource): PmxAxSource {
|
|
3493
|
+
return value === 'agent' ||
|
|
3494
|
+
value === 'api' ||
|
|
3495
|
+
value === 'browser' ||
|
|
3496
|
+
value === 'cli' ||
|
|
3497
|
+
value === 'codex' ||
|
|
3498
|
+
value === 'copilot' ||
|
|
3499
|
+
value === 'mcp' ||
|
|
3500
|
+
value === 'sdk' ||
|
|
3501
|
+
value === 'system'
|
|
3502
|
+
? value
|
|
3503
|
+
: fallback;
|
|
3504
|
+
}
|
|
3505
|
+
|
|
3506
|
+
function handleGetAxState(): Response {
|
|
3507
|
+
return responseJson({ ok: true, state: canvasState.getAxState() });
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3510
|
+
function handleGetAxContext(): Response {
|
|
3511
|
+
return responseJson(buildCanvasAxContext());
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
async function handleAxFocusUpdate(req: Request): Promise<Response> {
|
|
3515
|
+
const body = await readJson(req);
|
|
3516
|
+
const nodeIds = normalizeAxNodeIds(body.nodeIds);
|
|
3517
|
+
const source = normalizeAxSource(body.source, 'api');
|
|
3518
|
+
const focus = canvasState.setAxFocus(nodeIds, { source });
|
|
3519
|
+
broadcastWorkbenchEvent('ax-state-changed', {
|
|
3520
|
+
focus,
|
|
3521
|
+
sessionId: primaryWorkbenchSessionId,
|
|
3522
|
+
timestamp: new Date().toISOString(),
|
|
3523
|
+
});
|
|
3524
|
+
return responseJson({ ok: true, focus });
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
async function handleAxStatePatch(req: Request): Promise<Response> {
|
|
3528
|
+
const body = await readJson(req);
|
|
3529
|
+
if (!body.focus || typeof body.focus !== 'object' || Array.isArray(body.focus)) {
|
|
3530
|
+
return responseJson({ ok: false, error: 'PATCH /api/canvas/ax currently requires a focus object.' }, 400);
|
|
3531
|
+
}
|
|
3532
|
+
const focusInput = body.focus as Record<string, unknown>;
|
|
3533
|
+
const focus = canvasState.setAxFocus(normalizeAxNodeIds(focusInput.nodeIds), {
|
|
3534
|
+
source: normalizeAxSource(focusInput.source, 'api'),
|
|
3535
|
+
});
|
|
3536
|
+
broadcastWorkbenchEvent('ax-state-changed', {
|
|
3537
|
+
focus,
|
|
3538
|
+
sessionId: primaryWorkbenchSessionId,
|
|
3539
|
+
timestamp: new Date().toISOString(),
|
|
3540
|
+
});
|
|
3541
|
+
return responseJson({ ok: true, state: canvasState.getAxState() });
|
|
3542
|
+
}
|
|
3543
|
+
|
|
3394
3544
|
// ── Port resolution ───────────────────────────────────────────
|
|
3395
3545
|
|
|
3396
3546
|
function buildPortCandidates(preferredPort: number): number[] {
|
|
@@ -4075,6 +4225,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
4075
4225
|
|
|
4076
4226
|
// ── Canvas persistence: set workspace root and load saved state ──
|
|
4077
4227
|
canvasState.setWorkspaceRoot(activeWorkspaceRoot);
|
|
4228
|
+
canvasState.setTheme(initialCanvasThemeSetting as CanvasTheme);
|
|
4078
4229
|
const loaded = canvasState.loadFromDisk({ clearExisting: true });
|
|
4079
4230
|
setCanvasLayoutUpdateEmitter(() => {
|
|
4080
4231
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
@@ -4090,7 +4241,9 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
4090
4241
|
rotatePrimaryWorkbenchSessionIfNeeded();
|
|
4091
4242
|
|
|
4092
4243
|
const preferredPort = options.port ?? Number(process.env.PMX_WEB_CANVAS_PORT ?? DEFAULT_PORT);
|
|
4093
|
-
const portCandidates = options.
|
|
4244
|
+
const portCandidates = options.port === 0
|
|
4245
|
+
? [0]
|
|
4246
|
+
: options.allowPortFallback === false
|
|
4094
4247
|
? [preferredPort > 0 ? Math.floor(preferredPort) : DEFAULT_PORT]
|
|
4095
4248
|
: buildPortCandidates(preferredPort);
|
|
4096
4249
|
|
|
@@ -4119,6 +4272,14 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
4119
4272
|
return handleJsonRenderView(url);
|
|
4120
4273
|
}
|
|
4121
4274
|
|
|
4275
|
+
if (url.pathname === '/api/canvas/frame-documents' && req.method === 'POST') {
|
|
4276
|
+
return handleCreateFrameDocument(req);
|
|
4277
|
+
}
|
|
4278
|
+
|
|
4279
|
+
if (url.pathname.startsWith('/api/canvas/frame-documents/') && req.method === 'GET') {
|
|
4280
|
+
return handleFrameDocument(url.pathname);
|
|
4281
|
+
}
|
|
4282
|
+
|
|
4122
4283
|
if (url.pathname === '/' || url.pathname === '/workbench' || url.pathname === '/artifact') {
|
|
4123
4284
|
return new Response(canvasSpaHtml(), {
|
|
4124
4285
|
headers: {
|
|
@@ -4192,6 +4353,14 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
4192
4353
|
return responseJson(buildCanvasSummary());
|
|
4193
4354
|
}
|
|
4194
4355
|
|
|
4356
|
+
if (url.pathname === '/api/canvas/theme' && req.method === 'GET') {
|
|
4357
|
+
return responseJson({ ok: true, theme: canvasState.theme });
|
|
4358
|
+
}
|
|
4359
|
+
|
|
4360
|
+
if (url.pathname === '/api/canvas/theme' && req.method === 'POST') {
|
|
4361
|
+
return handleCanvasThemeUpdate(req);
|
|
4362
|
+
}
|
|
4363
|
+
|
|
4195
4364
|
if (url.pathname === '/api/canvas/update' && req.method === 'POST') {
|
|
4196
4365
|
return handleCanvasUpdate(req);
|
|
4197
4366
|
}
|
|
@@ -4334,6 +4503,22 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
4334
4503
|
return handleGetPinnedContext();
|
|
4335
4504
|
}
|
|
4336
4505
|
|
|
4506
|
+
if (url.pathname === '/api/canvas/ax' && req.method === 'GET') {
|
|
4507
|
+
return handleGetAxState();
|
|
4508
|
+
}
|
|
4509
|
+
|
|
4510
|
+
if (url.pathname === '/api/canvas/ax' && req.method === 'PATCH') {
|
|
4511
|
+
return handleAxStatePatch(req);
|
|
4512
|
+
}
|
|
4513
|
+
|
|
4514
|
+
if (url.pathname === '/api/canvas/ax/context' && req.method === 'GET') {
|
|
4515
|
+
return handleGetAxContext();
|
|
4516
|
+
}
|
|
4517
|
+
|
|
4518
|
+
if (url.pathname === '/api/canvas/ax/focus' && req.method === 'POST') {
|
|
4519
|
+
return handleAxFocusUpdate(req);
|
|
4520
|
+
}
|
|
4521
|
+
|
|
4337
4522
|
// Spatial context API
|
|
4338
4523
|
if (url.pathname === '/api/canvas/spatial-context' && req.method === 'GET') {
|
|
4339
4524
|
const layout = canvasState.getLayout();
|