pmx-canvas 0.1.23 → 0.1.25
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 +123 -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/McpAppNode.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-operations.d.ts +4 -0
- package/dist/types/server/canvas-state.d.ts +20 -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 +110 -0
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +125 -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/McpAppNode.tsx +13 -1
- 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 +3 -3
- 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 +96 -4
- package/src/server/canvas-state.ts +123 -4
- package/src/server/index.ts +29 -2
- package/src/server/mutation-history.ts +12 -0
- package/src/server/server.ts +312 -14
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';
|
|
@@ -46,7 +47,7 @@ import type {
|
|
|
46
47
|
ListResourceTemplatesResult,
|
|
47
48
|
ListToolsResult,
|
|
48
49
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
49
|
-
import { type CanvasAnnotation, type CanvasEdge, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
|
|
50
|
+
import { type CanvasAnnotation, type CanvasEdge, type CanvasLayout, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
|
|
50
51
|
import { findCanvasExtAppNodeId as findCanvasExtAppNodeIdShared } from './ext-app-lookup.js';
|
|
51
52
|
import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
|
|
52
53
|
import { getMcpAppHostSnapshot } from './mcp-app-host.js';
|
|
@@ -75,11 +76,15 @@ 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,
|
|
81
85
|
addCanvasEdge,
|
|
82
86
|
MARKDOWN_NODE_DEFAULT_SIZE,
|
|
87
|
+
MCP_APP_NODE_DEFAULT_SIZE,
|
|
83
88
|
applyCanvasNodeUpdates,
|
|
84
89
|
buildStructuredNodeUpdate,
|
|
85
90
|
arrangeCanvasNodes,
|
|
@@ -145,9 +150,7 @@ let nextWorkbenchSubscriberId = 1;
|
|
|
145
150
|
const workbenchSubscribers = new Map<number, ReadableStreamDefaultController<Uint8Array>>();
|
|
146
151
|
const textEncoder = new TextEncoder();
|
|
147
152
|
let primaryWorkbenchAutoOpenEnabled = true;
|
|
148
|
-
const
|
|
149
|
-
? process.env.PMX_CANVAS_THEME!
|
|
150
|
-
: 'dark');
|
|
153
|
+
const initialCanvasThemeSetting = normalizeCanvasTheme(process.env.PMX_CANVAS_THEME);
|
|
151
154
|
let lastWorkbenchContextCardsEnvelope: Record<string, unknown> | null = null;
|
|
152
155
|
|
|
153
156
|
function normalizeGraphViewerSpec(
|
|
@@ -788,6 +791,64 @@ function parseGraphPayloadData(value: unknown): Array<Record<string, unknown>> |
|
|
|
788
791
|
return value as Array<Record<string, unknown>>;
|
|
789
792
|
}
|
|
790
793
|
|
|
794
|
+
type StringListField = { value?: string[]; error?: string };
|
|
795
|
+
|
|
796
|
+
function parseStringListField(field: string, value: unknown): StringListField {
|
|
797
|
+
if (value === undefined) return {};
|
|
798
|
+
if (!Array.isArray(value)) return { error: `"${field}" must be an array of node IDs.` };
|
|
799
|
+
const invalid = value.find((item) => typeof item !== 'string' || item.trim().length === 0);
|
|
800
|
+
if (invalid !== undefined) return { error: `"${field}" must contain only non-empty node IDs.` };
|
|
801
|
+
return { value };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function pickGroupChildIds(body: Record<string, unknown>): StringListField {
|
|
805
|
+
if ('children' in body) return parseStringListField('children', body.children);
|
|
806
|
+
if ('childIds' in body) return parseStringListField('childIds', body.childIds);
|
|
807
|
+
const data = isRecord(body.data) ? body.data : undefined;
|
|
808
|
+
return data && 'children' in data ? parseStringListField('data.children', data.children) : {};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function validateGroupChildIds(groupId: string, childIds: string[]): string | null {
|
|
812
|
+
const missingChildIds = childIds.filter((id) => !canvasState.getNode(id));
|
|
813
|
+
if (missingChildIds.length > 0) {
|
|
814
|
+
return `Missing child node ID${missingChildIds.length === 1 ? '' : 's'}: ${missingChildIds.join(', ')}.`;
|
|
815
|
+
}
|
|
816
|
+
const invalidChildIds = childIds.filter((id) => {
|
|
817
|
+
const node = canvasState.getNode(id);
|
|
818
|
+
return id === groupId || node?.type === 'group';
|
|
819
|
+
});
|
|
820
|
+
if (invalidChildIds.length > 0) {
|
|
821
|
+
return `Invalid group child ID${invalidChildIds.length === 1 ? '' : 's'}: ${invalidChildIds.join(', ')}.`;
|
|
822
|
+
}
|
|
823
|
+
return null;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function setGroupChildrenFromApi(groupId: string, childIds: string[]): boolean {
|
|
827
|
+
const group = canvasState.getNode(groupId);
|
|
828
|
+
if (!group || group.type !== 'group') return false;
|
|
829
|
+
|
|
830
|
+
const dataChildIds = Array.isArray(group.data.children)
|
|
831
|
+
? group.data.children.filter((id): id is string => typeof id === 'string')
|
|
832
|
+
: [];
|
|
833
|
+
const parentBackrefIds = canvasState.getLayout().nodes
|
|
834
|
+
.filter((node) => node.id !== groupId && node.data.parentGroup === groupId)
|
|
835
|
+
.map((node) => node.id);
|
|
836
|
+
const currentChildIds = [...new Set([...dataChildIds, ...parentBackrefIds])];
|
|
837
|
+
if (currentChildIds.length > 0) {
|
|
838
|
+
if (currentChildIds.length !== dataChildIds.length || currentChildIds.some((id) => !dataChildIds.includes(id))) {
|
|
839
|
+
canvasState.updateNode(groupId, { data: { ...group.data, children: currentChildIds } });
|
|
840
|
+
}
|
|
841
|
+
canvasState.ungroupNodes(groupId);
|
|
842
|
+
}
|
|
843
|
+
if (childIds.length === 0) return true;
|
|
844
|
+
|
|
845
|
+
const latestGroup = canvasState.getNode(groupId);
|
|
846
|
+
return canvasState.groupNodes(groupId, childIds, {
|
|
847
|
+
preservePositions: true,
|
|
848
|
+
keepGroupFrame: latestGroup?.data.frameMode === 'manual',
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
|
|
791
852
|
function getExtAppNodeCheckpointId(node: CanvasNodeState): string {
|
|
792
853
|
const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
|
|
793
854
|
const storedCheckpointId = appCheckpoint?.id;
|
|
@@ -1038,6 +1099,24 @@ function normalizeMarkdownExternalUrls(markdown: string): string {
|
|
|
1038
1099
|
|
|
1039
1100
|
// ── Canvas SPA HTML ────────────────────────────────────────────
|
|
1040
1101
|
|
|
1102
|
+
const CANVAS_ASSET_VERSION = Date.now().toString(36);
|
|
1103
|
+
const MAX_FRAME_DOCUMENTS = 128;
|
|
1104
|
+
const MAX_FRAME_DOCUMENT_BYTES = 5 * 1024 * 1024;
|
|
1105
|
+
const DEFAULT_FRAME_DOCUMENT_SANDBOX = 'allow-scripts';
|
|
1106
|
+
const SAFE_FRAME_DOCUMENT_SANDBOX_TOKENS = new Set([
|
|
1107
|
+
'allow-downloads',
|
|
1108
|
+
'allow-forms',
|
|
1109
|
+
'allow-modals',
|
|
1110
|
+
'allow-orientation-lock',
|
|
1111
|
+
'allow-pointer-lock',
|
|
1112
|
+
'allow-popups',
|
|
1113
|
+
'allow-popups-to-escape-sandbox',
|
|
1114
|
+
'allow-presentation',
|
|
1115
|
+
'allow-scripts',
|
|
1116
|
+
'allow-storage-access-by-user-activation',
|
|
1117
|
+
]);
|
|
1118
|
+
const frameDocuments = new Map<string, { html: string; sandbox: string }>();
|
|
1119
|
+
|
|
1041
1120
|
function canvasSpaHtml(): string {
|
|
1042
1121
|
return `<!doctype html>
|
|
1043
1122
|
<html lang="en">
|
|
@@ -1106,7 +1185,7 @@ function canvasSpaHtml(): string {
|
|
|
1106
1185
|
color: #eef4ff;
|
|
1107
1186
|
}
|
|
1108
1187
|
</style>
|
|
1109
|
-
<link rel="stylesheet" href="/canvas/global.css" />
|
|
1188
|
+
<link rel="stylesheet" href="/canvas/global.css?v=${CANVAS_ASSET_VERSION}" />
|
|
1110
1189
|
</head>
|
|
1111
1190
|
<body>
|
|
1112
1191
|
<div id="canvasBootstrap">
|
|
@@ -1143,7 +1222,7 @@ function canvasSpaHtml(): string {
|
|
|
1143
1222
|
}, 4000);
|
|
1144
1223
|
})();
|
|
1145
1224
|
</script>
|
|
1146
|
-
<script type="module" src="/canvas/index.js"></script>
|
|
1225
|
+
<script type="module" src="/canvas/index.js?v=${CANVAS_ASSET_VERSION}"></script>
|
|
1147
1226
|
</body>
|
|
1148
1227
|
</html>`;
|
|
1149
1228
|
}
|
|
@@ -1229,6 +1308,61 @@ function serveCanvasFavicon(): Response {
|
|
|
1229
1308
|
|
|
1230
1309
|
// ── Canvas REST handlers ──────────────────────────────────────
|
|
1231
1310
|
|
|
1311
|
+
function normalizeFrameDocumentSandbox(value: unknown): string | null {
|
|
1312
|
+
if (value === undefined || value === null) return DEFAULT_FRAME_DOCUMENT_SANDBOX;
|
|
1313
|
+
if (typeof value !== 'string') return null;
|
|
1314
|
+
const tokens = value.trim().split(/\s+/).filter(Boolean);
|
|
1315
|
+
if (tokens.length === 0) return DEFAULT_FRAME_DOCUMENT_SANDBOX;
|
|
1316
|
+
const uniqueTokens: string[] = [];
|
|
1317
|
+
for (const token of tokens) {
|
|
1318
|
+
if (!SAFE_FRAME_DOCUMENT_SANDBOX_TOKENS.has(token)) return null;
|
|
1319
|
+
if (!uniqueTokens.includes(token)) uniqueTokens.push(token);
|
|
1320
|
+
}
|
|
1321
|
+
return uniqueTokens.join(' ');
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function addFrameDocument(html: string, sandbox: string): string {
|
|
1325
|
+
const id = randomUUID();
|
|
1326
|
+
frameDocuments.set(id, { html, sandbox });
|
|
1327
|
+
while (frameDocuments.size > MAX_FRAME_DOCUMENTS) {
|
|
1328
|
+
const firstKey = frameDocuments.keys().next().value;
|
|
1329
|
+
if (typeof firstKey !== 'string') break;
|
|
1330
|
+
frameDocuments.delete(firstKey);
|
|
1331
|
+
}
|
|
1332
|
+
return `/api/canvas/frame-documents/${id}`;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
async function handleCreateFrameDocument(req: Request): Promise<Response> {
|
|
1336
|
+
const body = await readJson(req);
|
|
1337
|
+
const html = body.html;
|
|
1338
|
+
if (typeof html !== 'string' || !html) {
|
|
1339
|
+
return responseJson({ ok: false, error: 'Frame document requires non-empty html.' }, 400);
|
|
1340
|
+
}
|
|
1341
|
+
if (new TextEncoder().encode(html).byteLength > MAX_FRAME_DOCUMENT_BYTES) {
|
|
1342
|
+
return responseJson({ ok: false, error: 'Frame document is too large.' }, 413);
|
|
1343
|
+
}
|
|
1344
|
+
const sandbox = normalizeFrameDocumentSandbox(body.sandbox);
|
|
1345
|
+
if (!sandbox) {
|
|
1346
|
+
return responseJson({ ok: false, error: 'Frame document sandbox contains unsupported tokens.' }, 400);
|
|
1347
|
+
}
|
|
1348
|
+
return responseJson({ ok: true, url: addFrameDocument(html, sandbox) });
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function handleFrameDocument(pathname: string): Response {
|
|
1352
|
+
const id = decodeURIComponent(pathname.slice('/api/canvas/frame-documents/'.length));
|
|
1353
|
+
const document = frameDocuments.get(id);
|
|
1354
|
+
if (!document) return responseText('Frame document not found.', 404);
|
|
1355
|
+
return new Response(document.html, {
|
|
1356
|
+
headers: {
|
|
1357
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1358
|
+
'Cache-Control': 'no-store',
|
|
1359
|
+
'Content-Security-Policy': `sandbox ${document.sandbox}`,
|
|
1360
|
+
'Referrer-Policy': 'no-referrer',
|
|
1361
|
+
'X-Content-Type-Options': 'nosniff',
|
|
1362
|
+
},
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1232
1366
|
async function handleCanvasUpdate(req: Request): Promise<Response> {
|
|
1233
1367
|
const body = await readJson(req);
|
|
1234
1368
|
const updates = Array.isArray(body.updates) ? body.updates : [];
|
|
@@ -1392,6 +1526,20 @@ function buildNodeResponse(node: CanvasNodeState): Record<string, unknown> {
|
|
|
1392
1526
|
};
|
|
1393
1527
|
}
|
|
1394
1528
|
|
|
1529
|
+
function withContextPinReadState(node: CanvasNodeState): CanvasNodeState {
|
|
1530
|
+
return {
|
|
1531
|
+
...node,
|
|
1532
|
+
pinned: node.pinned || canvasState.contextPinnedNodeIds.has(node.id),
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function withContextPinLayoutReadState(layout: CanvasLayout): CanvasLayout {
|
|
1537
|
+
return {
|
|
1538
|
+
...layout,
|
|
1539
|
+
nodes: layout.nodes.map(withContextPinReadState),
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1395
1543
|
async function createCanvasWebpageNode(body: Record<string, unknown>): Promise<Response> {
|
|
1396
1544
|
const rawUrl = typeof body.url === 'string' && body.url.trim().length > 0
|
|
1397
1545
|
? body.url
|
|
@@ -1472,6 +1620,23 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
|
|
|
1472
1620
|
return createCanvasHtmlPrimitiveNode(body);
|
|
1473
1621
|
}
|
|
1474
1622
|
|
|
1623
|
+
if (type === 'group') {
|
|
1624
|
+
const geometry = resolveCreateGeometry(body);
|
|
1625
|
+
const childList = pickGroupChildIds(body);
|
|
1626
|
+
if (childList.error) return responseJson({ ok: false, error: `Cannot create group: ${childList.error}` }, 400);
|
|
1627
|
+
const childIds = childList.value ?? [];
|
|
1628
|
+
const childError = validateGroupChildIds('', childIds);
|
|
1629
|
+
if (childError) return responseJson({ ok: false, error: `Cannot create group: ${childError}` }, 400);
|
|
1630
|
+
const { node } = createCanvasGroup({
|
|
1631
|
+
...(typeof body.title === 'string' ? { title: body.title } : {}),
|
|
1632
|
+
childIds,
|
|
1633
|
+
...(typeof body.color === 'string' ? { color: body.color } : {}),
|
|
1634
|
+
...geometry,
|
|
1635
|
+
});
|
|
1636
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1637
|
+
return responseJson(buildNodeResponse(node));
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1475
1640
|
const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
|
|
1476
1641
|
? body.data as Record<string, unknown>
|
|
1477
1642
|
: undefined;
|
|
@@ -1517,8 +1682,20 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
|
|
|
1517
1682
|
...(type === 'trace' && typeof body.error === 'string' ? { error: body.error } : {}),
|
|
1518
1683
|
...(body.strictSize === true ? { strictSize: true } : {}),
|
|
1519
1684
|
...geometry,
|
|
1520
|
-
defaultWidth: type === 'html'
|
|
1521
|
-
|
|
1685
|
+
defaultWidth: type === 'html'
|
|
1686
|
+
? 720
|
|
1687
|
+
: type === 'markdown'
|
|
1688
|
+
? MARKDOWN_NODE_DEFAULT_SIZE.width
|
|
1689
|
+
: type === 'mcp-app'
|
|
1690
|
+
? MCP_APP_NODE_DEFAULT_SIZE.width
|
|
1691
|
+
: 360,
|
|
1692
|
+
defaultHeight: type === 'html'
|
|
1693
|
+
? 640
|
|
1694
|
+
: type === 'markdown'
|
|
1695
|
+
? MARKDOWN_NODE_DEFAULT_SIZE.height
|
|
1696
|
+
: type === 'mcp-app'
|
|
1697
|
+
? MCP_APP_NODE_DEFAULT_SIZE.height
|
|
1698
|
+
: 200,
|
|
1522
1699
|
fileMode: 'auto',
|
|
1523
1700
|
});
|
|
1524
1701
|
} catch (error) {
|
|
@@ -1722,6 +1899,13 @@ async function handleCanvasUpdateNode(nodeId: string, req: Request): Promise<Res
|
|
|
1722
1899
|
if (existing.type === 'webpage' && body.refresh === true) {
|
|
1723
1900
|
return handleCanvasRefreshWebpageNode(nodeId, req);
|
|
1724
1901
|
}
|
|
1902
|
+
const groupChildList = existing.type === 'group' ? pickGroupChildIds(body) : {};
|
|
1903
|
+
if (groupChildList.error) return responseJson({ ok: false, error: `Cannot update group: ${groupChildList.error}` }, 400);
|
|
1904
|
+
const groupChildIds = groupChildList.value;
|
|
1905
|
+
if (groupChildIds !== undefined) {
|
|
1906
|
+
const childError = validateGroupChildIds(nodeId, groupChildIds);
|
|
1907
|
+
if (childError) return responseJson({ ok: false, error: `Cannot update group: ${childError}` }, 400);
|
|
1908
|
+
}
|
|
1725
1909
|
const patch: Record<string, unknown> = resolvePatchGeometry(body, existing);
|
|
1726
1910
|
if (body.collapsed !== undefined) patch.collapsed = body.collapsed;
|
|
1727
1911
|
if (body.pinned !== undefined) patch.pinned = Boolean(body.pinned);
|
|
@@ -1780,6 +1964,9 @@ async function handleCanvasUpdateNode(nodeId: string, req: Request): Promise<Res
|
|
|
1780
1964
|
});
|
|
1781
1965
|
if (error) return responseJson({ ok: false, error }, 400);
|
|
1782
1966
|
canvasState.updateNode(nodeId, patch as Partial<CanvasNodeState>);
|
|
1967
|
+
if (groupChildIds !== undefined && !setGroupChildrenFromApi(nodeId, groupChildIds)) {
|
|
1968
|
+
return responseJson({ ok: false, error: `Group "${nodeId}" not found.` }, 404);
|
|
1969
|
+
}
|
|
1783
1970
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1784
1971
|
const updated = canvasState.getNode(nodeId);
|
|
1785
1972
|
return responseJson(updated ? buildNodeResponse(updated) : { ok: true, id: nodeId });
|
|
@@ -1817,10 +2004,16 @@ async function handleCanvasFocus(req: Request): Promise<Response> {
|
|
|
1817
2004
|
const maxZ = canvasState.getLayout().nodes.reduce((max, layoutNode) => Math.max(max, layoutNode.zIndex), 0);
|
|
1818
2005
|
canvasState.updateNode(nodeId, { zIndex: maxZ + 1 });
|
|
1819
2006
|
}
|
|
2007
|
+
const focus = canvasState.setAxFocus([nodeId], { source: 'api', recordHistory: false });
|
|
2008
|
+
broadcastWorkbenchEvent('ax-state-changed', {
|
|
2009
|
+
focus,
|
|
2010
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2011
|
+
timestamp: new Date().toISOString(),
|
|
2012
|
+
});
|
|
1820
2013
|
emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId, noPan });
|
|
1821
2014
|
if (!noPan) emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
|
|
1822
2015
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1823
|
-
return responseJson({ ok: true, focused: nodeId, panned: !noPan });
|
|
2016
|
+
return responseJson({ ok: true, focused: nodeId, panned: !noPan, axFocus: focus });
|
|
1824
2017
|
}
|
|
1825
2018
|
|
|
1826
2019
|
async function handleCanvasFit(req: Request): Promise<Response> {
|
|
@@ -2105,6 +2298,18 @@ function handleCanvasValidate(): Response {
|
|
|
2105
2298
|
return responseJson(validateCanvasLayout(canvasState.getLayout()));
|
|
2106
2299
|
}
|
|
2107
2300
|
|
|
2301
|
+
async function handleCanvasThemeUpdate(req: Request): Promise<Response> {
|
|
2302
|
+
const body = await readJson(req);
|
|
2303
|
+
const theme = normalizeCanvasTheme(body.theme, canvasState.theme);
|
|
2304
|
+
const next = canvasState.setTheme(theme);
|
|
2305
|
+
broadcastWorkbenchEvent('theme-changed', {
|
|
2306
|
+
theme: next,
|
|
2307
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2308
|
+
timestamp: new Date().toISOString(),
|
|
2309
|
+
});
|
|
2310
|
+
return responseJson({ ok: true, theme: next });
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2108
2313
|
async function handleJsonRenderView(url: URL): Promise<Response> {
|
|
2109
2314
|
const nodeId = url.searchParams.get('nodeId') ?? '';
|
|
2110
2315
|
if (!nodeId) return responseText('Missing nodeId', 400);
|
|
@@ -2973,7 +3178,7 @@ function handleWorkbenchEvents(req: Request): Response {
|
|
|
2973
3178
|
requestedSessionId: requestedSessionId || null,
|
|
2974
3179
|
continuity,
|
|
2975
3180
|
path: primaryWorkbenchPath,
|
|
2976
|
-
theme:
|
|
3181
|
+
theme: canvasState.theme,
|
|
2977
3182
|
timestamp: new Date().toISOString(),
|
|
2978
3183
|
}),
|
|
2979
3184
|
);
|
|
@@ -3391,6 +3596,63 @@ function handleGetPinnedContext(): Response {
|
|
|
3391
3596
|
return responseJson({ preamble, nodeIds: pinnedIds, count: pinnedIds.length, nodes });
|
|
3392
3597
|
}
|
|
3393
3598
|
|
|
3599
|
+
function normalizeAxNodeIds(value: unknown): string[] {
|
|
3600
|
+
if (!Array.isArray(value)) return [];
|
|
3601
|
+
return value.filter((id): id is string => typeof id === 'string');
|
|
3602
|
+
}
|
|
3603
|
+
|
|
3604
|
+
function normalizeAxSource(value: unknown, fallback: PmxAxSource): PmxAxSource {
|
|
3605
|
+
return value === 'agent' ||
|
|
3606
|
+
value === 'api' ||
|
|
3607
|
+
value === 'browser' ||
|
|
3608
|
+
value === 'cli' ||
|
|
3609
|
+
value === 'codex' ||
|
|
3610
|
+
value === 'copilot' ||
|
|
3611
|
+
value === 'mcp' ||
|
|
3612
|
+
value === 'sdk' ||
|
|
3613
|
+
value === 'system'
|
|
3614
|
+
? value
|
|
3615
|
+
: fallback;
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3618
|
+
function handleGetAxState(): Response {
|
|
3619
|
+
return responseJson({ ok: true, state: canvasState.getAxState() });
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3622
|
+
function handleGetAxContext(): Response {
|
|
3623
|
+
return responseJson(buildCanvasAxContext());
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
async function handleAxFocusUpdate(req: Request): Promise<Response> {
|
|
3627
|
+
const body = await readJson(req);
|
|
3628
|
+
const nodeIds = normalizeAxNodeIds(body.nodeIds);
|
|
3629
|
+
const source = normalizeAxSource(body.source, 'api');
|
|
3630
|
+
const focus = canvasState.setAxFocus(nodeIds, { source });
|
|
3631
|
+
broadcastWorkbenchEvent('ax-state-changed', {
|
|
3632
|
+
focus,
|
|
3633
|
+
sessionId: primaryWorkbenchSessionId,
|
|
3634
|
+
timestamp: new Date().toISOString(),
|
|
3635
|
+
});
|
|
3636
|
+
return responseJson({ ok: true, focus });
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3639
|
+
async function handleAxStatePatch(req: Request): Promise<Response> {
|
|
3640
|
+
const body = await readJson(req);
|
|
3641
|
+
if (!body.focus || typeof body.focus !== 'object' || Array.isArray(body.focus)) {
|
|
3642
|
+
return responseJson({ ok: false, error: 'PATCH /api/canvas/ax currently requires a focus object.' }, 400);
|
|
3643
|
+
}
|
|
3644
|
+
const focusInput = body.focus as Record<string, unknown>;
|
|
3645
|
+
const focus = canvasState.setAxFocus(normalizeAxNodeIds(focusInput.nodeIds), {
|
|
3646
|
+
source: normalizeAxSource(focusInput.source, 'api'),
|
|
3647
|
+
});
|
|
3648
|
+
broadcastWorkbenchEvent('ax-state-changed', {
|
|
3649
|
+
focus,
|
|
3650
|
+
sessionId: primaryWorkbenchSessionId,
|
|
3651
|
+
timestamp: new Date().toISOString(),
|
|
3652
|
+
});
|
|
3653
|
+
return responseJson({ ok: true, state: canvasState.getAxState() });
|
|
3654
|
+
}
|
|
3655
|
+
|
|
3394
3656
|
// ── Port resolution ───────────────────────────────────────────
|
|
3395
3657
|
|
|
3396
3658
|
function buildPortCandidates(preferredPort: number): number[] {
|
|
@@ -4075,6 +4337,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
4075
4337
|
|
|
4076
4338
|
// ── Canvas persistence: set workspace root and load saved state ──
|
|
4077
4339
|
canvasState.setWorkspaceRoot(activeWorkspaceRoot);
|
|
4340
|
+
canvasState.setTheme(initialCanvasThemeSetting as CanvasTheme);
|
|
4078
4341
|
const loaded = canvasState.loadFromDisk({ clearExisting: true });
|
|
4079
4342
|
setCanvasLayoutUpdateEmitter(() => {
|
|
4080
4343
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
@@ -4090,7 +4353,9 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
4090
4353
|
rotatePrimaryWorkbenchSessionIfNeeded();
|
|
4091
4354
|
|
|
4092
4355
|
const preferredPort = options.port ?? Number(process.env.PMX_WEB_CANVAS_PORT ?? DEFAULT_PORT);
|
|
4093
|
-
const portCandidates = options.
|
|
4356
|
+
const portCandidates = options.port === 0
|
|
4357
|
+
? [0]
|
|
4358
|
+
: options.allowPortFallback === false
|
|
4094
4359
|
? [preferredPort > 0 ? Math.floor(preferredPort) : DEFAULT_PORT]
|
|
4095
4360
|
: buildPortCandidates(preferredPort);
|
|
4096
4361
|
|
|
@@ -4119,6 +4384,14 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
4119
4384
|
return handleJsonRenderView(url);
|
|
4120
4385
|
}
|
|
4121
4386
|
|
|
4387
|
+
if (url.pathname === '/api/canvas/frame-documents' && req.method === 'POST') {
|
|
4388
|
+
return handleCreateFrameDocument(req);
|
|
4389
|
+
}
|
|
4390
|
+
|
|
4391
|
+
if (url.pathname.startsWith('/api/canvas/frame-documents/') && req.method === 'GET') {
|
|
4392
|
+
return handleFrameDocument(url.pathname);
|
|
4393
|
+
}
|
|
4394
|
+
|
|
4122
4395
|
if (url.pathname === '/' || url.pathname === '/workbench' || url.pathname === '/artifact') {
|
|
4123
4396
|
return new Response(canvasSpaHtml(), {
|
|
4124
4397
|
headers: {
|
|
@@ -4185,13 +4458,21 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
4185
4458
|
const includeBlobs = url.searchParams.get('includeBlobs') === 'true';
|
|
4186
4459
|
return responseJson(includeBlobs
|
|
4187
4460
|
? serializeCanvasLayout(canvasState.getLayout())
|
|
4188
|
-
: serializeCanvasLayoutWithBlobSummaries(canvasState.getLayoutForPersistence()));
|
|
4461
|
+
: serializeCanvasLayoutWithBlobSummaries(withContextPinLayoutReadState(canvasState.getLayoutForPersistence())));
|
|
4189
4462
|
}
|
|
4190
4463
|
|
|
4191
4464
|
if (url.pathname === '/api/canvas/summary' && req.method === 'GET') {
|
|
4192
4465
|
return responseJson(buildCanvasSummary());
|
|
4193
4466
|
}
|
|
4194
4467
|
|
|
4468
|
+
if (url.pathname === '/api/canvas/theme' && req.method === 'GET') {
|
|
4469
|
+
return responseJson({ ok: true, theme: canvasState.theme });
|
|
4470
|
+
}
|
|
4471
|
+
|
|
4472
|
+
if (url.pathname === '/api/canvas/theme' && req.method === 'POST') {
|
|
4473
|
+
return handleCanvasThemeUpdate(req);
|
|
4474
|
+
}
|
|
4475
|
+
|
|
4195
4476
|
if (url.pathname === '/api/canvas/update' && req.method === 'POST') {
|
|
4196
4477
|
return handleCanvasUpdate(req);
|
|
4197
4478
|
}
|
|
@@ -4247,9 +4528,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
4247
4528
|
const includeBlobs = url.searchParams.get('includeBlobs') === 'true';
|
|
4248
4529
|
const node = includeBlobs ? canvasState.getNode(nodeId) : canvasState.getNodeForPersistence(nodeId);
|
|
4249
4530
|
if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
|
|
4531
|
+
const responseNode = withContextPinReadState(node);
|
|
4250
4532
|
return responseJson(includeBlobs
|
|
4251
|
-
? serializeCanvasNode(
|
|
4252
|
-
: serializeCanvasNodeWithBlobSummaries(
|
|
4533
|
+
? serializeCanvasNode(responseNode)
|
|
4534
|
+
: serializeCanvasNodeWithBlobSummaries(responseNode));
|
|
4253
4535
|
}
|
|
4254
4536
|
|
|
4255
4537
|
if (url.pathname.startsWith('/api/canvas/node/') && req.method === 'PATCH') {
|
|
@@ -4334,6 +4616,22 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
4334
4616
|
return handleGetPinnedContext();
|
|
4335
4617
|
}
|
|
4336
4618
|
|
|
4619
|
+
if (url.pathname === '/api/canvas/ax' && req.method === 'GET') {
|
|
4620
|
+
return handleGetAxState();
|
|
4621
|
+
}
|
|
4622
|
+
|
|
4623
|
+
if (url.pathname === '/api/canvas/ax' && req.method === 'PATCH') {
|
|
4624
|
+
return handleAxStatePatch(req);
|
|
4625
|
+
}
|
|
4626
|
+
|
|
4627
|
+
if (url.pathname === '/api/canvas/ax/context' && req.method === 'GET') {
|
|
4628
|
+
return handleGetAxContext();
|
|
4629
|
+
}
|
|
4630
|
+
|
|
4631
|
+
if (url.pathname === '/api/canvas/ax/focus' && req.method === 'POST') {
|
|
4632
|
+
return handleAxFocusUpdate(req);
|
|
4633
|
+
}
|
|
4634
|
+
|
|
4337
4635
|
// Spatial context API
|
|
4338
4636
|
if (url.pathname === '/api/canvas/spatial-context' && req.method === 'GET') {
|
|
4339
4637
|
const layout = canvasState.getLayout();
|