pmx-canvas 0.1.22 → 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 +140 -0
- package/Readme.md +40 -8
- 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 +38 -0
- package/dist/types/server/canvas-state.d.ts +36 -16
- 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 +3 -2
- package/skills/pmx-canvas/SKILL.md +22 -4
- 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 +745 -0
- package/src/server/canvas-operations.ts +80 -1
- package/src/server/canvas-schema.ts +3 -3
- package/src/server/canvas-state.ts +390 -50
- package/src/server/canvas-validation.ts +6 -0
- package/src/server/index.ts +18 -0
- package/src/server/mutation-history.ts +1 -0
- package/src/server/server.ts +197 -11
package/src/client/types.ts
CHANGED
|
@@ -108,6 +108,7 @@ export function isExcalidrawNode(node: CanvasNodeState): boolean {
|
|
|
108
108
|
|
|
109
109
|
export interface CanvasLayout {
|
|
110
110
|
viewport: ViewportState;
|
|
111
|
+
theme?: 'dark' | 'light' | 'high-contrast';
|
|
111
112
|
nodes: CanvasNodeState[];
|
|
112
113
|
edges: CanvasEdge[];
|
|
113
114
|
annotations?: CanvasAnnotation[];
|
package/src/mcp/canvas-access.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type CanvasSnapshot,
|
|
10
10
|
type PmxCanvas,
|
|
11
11
|
} from '../server/index.js';
|
|
12
|
+
import type { PmxAxSource } from '../server/ax-state.js';
|
|
12
13
|
|
|
13
14
|
type AddNodeInput = Parameters<PmxCanvas['addNode']>[0];
|
|
14
15
|
type AddWebpageNodeInput = Parameters<PmxCanvas['addWebpageNode']>[0];
|
|
@@ -31,6 +32,9 @@ type ArrangeLayout = Parameters<PmxCanvas['arrange']>[0];
|
|
|
31
32
|
type FocusNodeResult = ReturnType<PmxCanvas['focusNode']>;
|
|
32
33
|
type FitViewOptions = Parameters<PmxCanvas['fitView']>[0];
|
|
33
34
|
type FitViewResult = ReturnType<PmxCanvas['fitView']>;
|
|
35
|
+
type AxStateResult = ReturnType<PmxCanvas['getAxState']>;
|
|
36
|
+
type AxContextResult = ReturnType<PmxCanvas['getAxContext']>;
|
|
37
|
+
type SetAxFocusResult = ReturnType<PmxCanvas['setAxFocus']>;
|
|
34
38
|
type SearchResult = ReturnType<PmxCanvas['search']>;
|
|
35
39
|
type UndoRedoResult = Awaited<ReturnType<PmxCanvas['undo']>>;
|
|
36
40
|
type HistoryResult = ReturnType<PmxCanvas['getHistory']>;
|
|
@@ -118,6 +122,9 @@ export interface CanvasAccess {
|
|
|
118
122
|
arrange(layout?: ArrangeLayout): Promise<void>;
|
|
119
123
|
focusNode(id: string, options?: { noPan?: boolean }): Promise<FocusNodeResult>;
|
|
120
124
|
fitView(options?: FitViewOptions): Promise<FitViewResult>;
|
|
125
|
+
getAxState(): Promise<AxStateResult>;
|
|
126
|
+
getAxContext(): Promise<AxContextResult>;
|
|
127
|
+
setAxFocus(nodeIds: string[], options?: { source?: PmxAxSource }): Promise<SetAxFocusResult>;
|
|
121
128
|
clear(): Promise<void>;
|
|
122
129
|
search(query: string): Promise<SearchResult>;
|
|
123
130
|
undo(): Promise<UndoRedoResult>;
|
|
@@ -247,6 +254,18 @@ class LocalCanvasAccess implements CanvasAccess {
|
|
|
247
254
|
return this.canvas.fitView(options);
|
|
248
255
|
}
|
|
249
256
|
|
|
257
|
+
async getAxState(): Promise<AxStateResult> {
|
|
258
|
+
return this.canvas.getAxState();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async getAxContext(): Promise<AxContextResult> {
|
|
262
|
+
return this.canvas.getAxContext();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async setAxFocus(nodeIds: string[], options?: { source?: PmxAxSource }): Promise<SetAxFocusResult> {
|
|
266
|
+
return this.canvas.setAxFocus(nodeIds, { source: options?.source ?? 'mcp' });
|
|
267
|
+
}
|
|
268
|
+
|
|
250
269
|
async clear(): Promise<void> {
|
|
251
270
|
this.canvas.clear();
|
|
252
271
|
}
|
|
@@ -587,6 +606,25 @@ class RemoteCanvasAccess implements CanvasAccess {
|
|
|
587
606
|
return await this.requestJson<HistoryResult>('GET', '/api/canvas/history');
|
|
588
607
|
}
|
|
589
608
|
|
|
609
|
+
async getAxState(): Promise<AxStateResult> {
|
|
610
|
+
const response = await this.requestJson<{ state?: AxStateResult }>('GET', '/api/canvas/ax');
|
|
611
|
+
if (!response.state) throw new Error('Remote canvas did not return AX state.');
|
|
612
|
+
return response.state;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async getAxContext(): Promise<AxContextResult> {
|
|
616
|
+
return await this.requestJson<AxContextResult>('GET', '/api/canvas/ax/context');
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async setAxFocus(nodeIds: string[], options?: { source?: PmxAxSource }): Promise<SetAxFocusResult> {
|
|
620
|
+
const response = await this.requestJson<{ focus?: SetAxFocusResult }>('POST', '/api/canvas/ax/focus', {
|
|
621
|
+
nodeIds,
|
|
622
|
+
source: options?.source ?? 'mcp',
|
|
623
|
+
});
|
|
624
|
+
if (!response.focus) throw new Error('Remote canvas did not return AX focus.');
|
|
625
|
+
return response.focus;
|
|
626
|
+
}
|
|
627
|
+
|
|
590
628
|
async setContextPins(nodeIds: string[], mode: 'set' | 'add' | 'remove' = 'set'): Promise<SetContextPinsResult> {
|
|
591
629
|
const existing = mode === 'set' ? [] : await this.getPinnedNodeIds();
|
|
592
630
|
const requested = new Set(nodeIds);
|
package/src/mcp/server.ts
CHANGED
|
@@ -100,13 +100,17 @@ function sleep(ms: number): Promise<void> {
|
|
|
100
100
|
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
function sendCanvasResourceNotifications(type: 'nodes' | 'pins' = 'nodes'): void {
|
|
103
|
+
function sendCanvasResourceNotifications(type: 'nodes' | 'pins' | 'ax' = 'nodes'): void {
|
|
104
104
|
const server = resourceNotificationServer;
|
|
105
105
|
if (!server) return;
|
|
106
106
|
try {
|
|
107
107
|
if (type === 'pins') {
|
|
108
108
|
server.server.sendResourceUpdated({ uri: 'canvas://pinned-context' });
|
|
109
109
|
}
|
|
110
|
+
if (type === 'pins' || type === 'ax') {
|
|
111
|
+
server.server.sendResourceUpdated({ uri: 'canvas://ax' });
|
|
112
|
+
server.server.sendResourceUpdated({ uri: 'canvas://ax-context' });
|
|
113
|
+
}
|
|
110
114
|
server.server.sendResourceUpdated({ uri: 'canvas://layout' });
|
|
111
115
|
server.server.sendResourceUpdated({ uri: 'canvas://summary' });
|
|
112
116
|
server.server.sendResourceUpdated({ uri: 'canvas://spatial-context' });
|
|
@@ -121,7 +125,9 @@ function handleRemoteSseFrame(frame: string): void {
|
|
|
121
125
|
const eventLine = frame.split('\n').find((line) => line.startsWith('event: '));
|
|
122
126
|
const event = eventLine?.slice('event: '.length).trim() ?? '';
|
|
123
127
|
if (!event || event === 'connected' || event === 'ping') return;
|
|
124
|
-
sendCanvasResourceNotifications(
|
|
128
|
+
sendCanvasResourceNotifications(
|
|
129
|
+
event === 'context-pins-changed' ? 'pins' : event === 'ax-state-changed' ? 'ax' : 'nodes',
|
|
130
|
+
);
|
|
125
131
|
}
|
|
126
132
|
|
|
127
133
|
async function watchRemoteCanvasEvents(baseUrl: string): Promise<void> {
|
|
@@ -904,7 +910,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
904
910
|
// ── canvas_update_node ─────────────────────────────────────────
|
|
905
911
|
server.tool(
|
|
906
912
|
'canvas_update_node',
|
|
907
|
-
'Update an existing node. You can change its content, title, position, size, or data.',
|
|
913
|
+
'Update an existing node. You can change its content, title, position, size, dock placement, or data.',
|
|
908
914
|
{
|
|
909
915
|
id: z.string().describe('Node ID to update'),
|
|
910
916
|
title: z.string().optional().describe('New title'),
|
|
@@ -926,12 +932,14 @@ export async function startMcpServer(): Promise<void> {
|
|
|
926
932
|
resultSummary: z.string().optional().describe('Trace node result summary'),
|
|
927
933
|
error: z.string().optional().describe('Trace node error message'),
|
|
928
934
|
collapsed: z.boolean().optional().describe('Collapse or expand the node'),
|
|
935
|
+
dockPosition: z.enum(['left', 'right']).nullable().optional().describe('Dock the node to the left/right HUD column, or pass null to return it to the canvas'),
|
|
936
|
+
pinned: z.boolean().optional().describe('Pin or unpin the node to exclude it from auto-arrange'),
|
|
929
937
|
arrangeLocked: z.boolean().optional().describe('Prevent auto-arrange from moving this node. Pinned nodes are also skipped.'),
|
|
930
938
|
full: z.boolean().optional().describe('Return the full updated node payload. Default false returns compact metadata.'),
|
|
931
939
|
verbose: z.boolean().optional().describe('Alias for full:true.'),
|
|
932
940
|
},
|
|
933
941
|
async (input) => {
|
|
934
|
-
const { id, title, content, x, y, width, height, spec, graphType, data, xKey, yKey, chartHeight, collapsed, arrangeLocked, toolName, category, status, duration, resultSummary, error } = input;
|
|
942
|
+
const { id, title, content, x, y, width, height, spec, graphType, data, xKey, yKey, chartHeight, collapsed, dockPosition, pinned, arrangeLocked, toolName, category, status, duration, resultSummary, error } = input;
|
|
935
943
|
const c = await ensureCanvas();
|
|
936
944
|
const node = await c.getNode(id);
|
|
937
945
|
if (!node) {
|
|
@@ -950,6 +958,12 @@ export async function startMcpServer(): Promise<void> {
|
|
|
950
958
|
if (collapsed !== undefined) {
|
|
951
959
|
patch.collapsed = collapsed;
|
|
952
960
|
}
|
|
961
|
+
if (dockPosition !== undefined) {
|
|
962
|
+
patch.dockPosition = dockPosition;
|
|
963
|
+
}
|
|
964
|
+
if (pinned !== undefined) {
|
|
965
|
+
patch.pinned = pinned;
|
|
966
|
+
}
|
|
953
967
|
if (title !== undefined) patch.title = title;
|
|
954
968
|
if (content !== undefined) patch.content = content;
|
|
955
969
|
if (spec !== undefined) patch.spec = spec;
|
|
@@ -1120,6 +1134,55 @@ export async function startMcpServer(): Promise<void> {
|
|
|
1120
1134
|
},
|
|
1121
1135
|
);
|
|
1122
1136
|
|
|
1137
|
+
// ── AX context and focus ───────────────────────────────────────
|
|
1138
|
+
server.tool(
|
|
1139
|
+
'canvas_get_ax',
|
|
1140
|
+
'Read the host-agnostic PMX AX state and agent-ready AX context. Use this when you need pinned context plus the current focus field.',
|
|
1141
|
+
{
|
|
1142
|
+
includeContext: z.boolean().optional().describe('Include serialized agent-ready AX context. Default true.'),
|
|
1143
|
+
},
|
|
1144
|
+
async ({ includeContext }) => {
|
|
1145
|
+
const c = await ensureCanvas();
|
|
1146
|
+
const state = await c.getAxState();
|
|
1147
|
+
const context = includeContext === false ? undefined : await c.getAxContext();
|
|
1148
|
+
return {
|
|
1149
|
+
content: [
|
|
1150
|
+
{
|
|
1151
|
+
type: 'text',
|
|
1152
|
+
text: JSON.stringify({
|
|
1153
|
+
ok: true,
|
|
1154
|
+
state,
|
|
1155
|
+
...(context ? { context } : {}),
|
|
1156
|
+
}),
|
|
1157
|
+
},
|
|
1158
|
+
],
|
|
1159
|
+
};
|
|
1160
|
+
},
|
|
1161
|
+
);
|
|
1162
|
+
|
|
1163
|
+
server.tool(
|
|
1164
|
+
'canvas_set_ax_focus',
|
|
1165
|
+
'Set the PMX AX focus field without requiring viewport movement. Focus is persisted and available through canvas://ax-context.',
|
|
1166
|
+
{
|
|
1167
|
+
nodeIds: z.array(z.string()).describe('Node IDs to place in the AX focus field. Missing nodes are ignored.'),
|
|
1168
|
+
source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
|
|
1169
|
+
.optional()
|
|
1170
|
+
.describe('Optional host/source label for adapter-originated focus. Defaults to mcp. Use codex from the Codex app adapter.'),
|
|
1171
|
+
},
|
|
1172
|
+
async ({ nodeIds, source }) => {
|
|
1173
|
+
const c = await ensureCanvas();
|
|
1174
|
+
const focus = await c.setAxFocus(nodeIds, { source: source ?? 'mcp' });
|
|
1175
|
+
return {
|
|
1176
|
+
content: [
|
|
1177
|
+
{
|
|
1178
|
+
type: 'text',
|
|
1179
|
+
text: JSON.stringify({ ok: true, focus }),
|
|
1180
|
+
},
|
|
1181
|
+
],
|
|
1182
|
+
};
|
|
1183
|
+
},
|
|
1184
|
+
);
|
|
1185
|
+
|
|
1123
1186
|
server.tool(
|
|
1124
1187
|
'canvas_fit_view',
|
|
1125
1188
|
'Fit the canvas viewport to all nodes or a selected subset. Useful before screenshots and whole-board review.',
|
|
@@ -1497,6 +1560,52 @@ export async function startMcpServer(): Promise<void> {
|
|
|
1497
1560
|
},
|
|
1498
1561
|
);
|
|
1499
1562
|
|
|
1563
|
+
server.resource(
|
|
1564
|
+
'ax-state',
|
|
1565
|
+
'canvas://ax',
|
|
1566
|
+
{
|
|
1567
|
+
description:
|
|
1568
|
+
'Host-agnostic PMX AX state. This includes canvas-bound collaboration primitives such as the current AX focus.',
|
|
1569
|
+
mimeType: 'application/json',
|
|
1570
|
+
},
|
|
1571
|
+
async () => {
|
|
1572
|
+
const c = await ensureCanvas();
|
|
1573
|
+
const state = await c.getAxState();
|
|
1574
|
+
return {
|
|
1575
|
+
contents: [
|
|
1576
|
+
{
|
|
1577
|
+
uri: 'canvas://ax',
|
|
1578
|
+
mimeType: 'application/json',
|
|
1579
|
+
text: JSON.stringify({ state }, null, 2),
|
|
1580
|
+
},
|
|
1581
|
+
],
|
|
1582
|
+
};
|
|
1583
|
+
},
|
|
1584
|
+
);
|
|
1585
|
+
|
|
1586
|
+
server.resource(
|
|
1587
|
+
'ax-context',
|
|
1588
|
+
'canvas://ax-context',
|
|
1589
|
+
{
|
|
1590
|
+
description:
|
|
1591
|
+
'Agent-ready PMX AX context combining pinned context, focus, and surface metadata.',
|
|
1592
|
+
mimeType: 'application/json',
|
|
1593
|
+
},
|
|
1594
|
+
async () => {
|
|
1595
|
+
const c = await ensureCanvas();
|
|
1596
|
+
const context = await c.getAxContext();
|
|
1597
|
+
return {
|
|
1598
|
+
contents: [
|
|
1599
|
+
{
|
|
1600
|
+
uri: 'canvas://ax-context',
|
|
1601
|
+
mimeType: 'application/json',
|
|
1602
|
+
text: JSON.stringify(context, null, 2),
|
|
1603
|
+
},
|
|
1604
|
+
],
|
|
1605
|
+
};
|
|
1606
|
+
},
|
|
1607
|
+
);
|
|
1608
|
+
|
|
1500
1609
|
server.resource(
|
|
1501
1610
|
'canvas-layout',
|
|
1502
1611
|
'canvas://layout',
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
|
|
2
|
+
import { buildAxContext, type PmxAxContext, type PmxAxPinnedContext } from './ax-state.js';
|
|
3
|
+
import { canvasState, type CanvasNodeState } from './canvas-state.js';
|
|
4
|
+
|
|
5
|
+
function serializeNodes(nodes: CanvasNodeState[]) {
|
|
6
|
+
return nodes.map((node) => serializeNodeForAgentContext(node, {
|
|
7
|
+
defaultTextLength: 700,
|
|
8
|
+
webpageTextLength: 1600,
|
|
9
|
+
includePosition: true,
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function buildCanvasAxPinnedContext(): PmxAxPinnedContext {
|
|
14
|
+
const nodeIds = Array.from(canvasState.contextPinnedNodeIds);
|
|
15
|
+
const nodes = nodeIds
|
|
16
|
+
.map((id) => canvasState.getNode(id))
|
|
17
|
+
.filter((node): node is CanvasNodeState => node !== undefined);
|
|
18
|
+
return {
|
|
19
|
+
preamble: nodes.length > 0 ? buildAgentContextPreamble(nodes) : '',
|
|
20
|
+
nodeIds,
|
|
21
|
+
count: nodeIds.length,
|
|
22
|
+
nodes: serializeNodes(nodes),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildCanvasAxContext(): PmxAxContext {
|
|
27
|
+
const layout = canvasState.getLayout();
|
|
28
|
+
const focus = canvasState.getAxFocus();
|
|
29
|
+
const focusNodes = focus.nodeIds
|
|
30
|
+
.map((id) => canvasState.getNode(id))
|
|
31
|
+
.filter((node): node is CanvasNodeState => node !== undefined);
|
|
32
|
+
return buildAxContext({
|
|
33
|
+
layout,
|
|
34
|
+
pinned: buildCanvasAxPinnedContext(),
|
|
35
|
+
focus,
|
|
36
|
+
focusNodes: serializeNodes(focusNodes),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { CanvasLayout, CanvasNodeState } from './canvas-state.js';
|
|
2
|
+
import type { AgentContextNode } from './agent-context.js';
|
|
3
|
+
|
|
4
|
+
export type PmxAxSource = 'agent' | 'api' | 'browser' | 'cli' | 'codex' | 'copilot' | 'mcp' | 'sdk' | 'system';
|
|
5
|
+
|
|
6
|
+
export interface PmxAxFocusState {
|
|
7
|
+
nodeIds: string[];
|
|
8
|
+
primaryNodeId: string | null;
|
|
9
|
+
updatedAt: string | null;
|
|
10
|
+
source: PmxAxSource | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PmxAxState {
|
|
14
|
+
version: 1;
|
|
15
|
+
focus: PmxAxFocusState;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PmxAxPinnedContext {
|
|
19
|
+
preamble: string;
|
|
20
|
+
nodeIds: string[];
|
|
21
|
+
count: number;
|
|
22
|
+
nodes: AgentContextNode[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PmxAxFocusContext extends PmxAxFocusState {
|
|
26
|
+
nodes: AgentContextNode[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PmxAxContext {
|
|
30
|
+
version: 1;
|
|
31
|
+
generatedAt: string;
|
|
32
|
+
surface: {
|
|
33
|
+
nodeCount: number;
|
|
34
|
+
edgeCount: number;
|
|
35
|
+
};
|
|
36
|
+
pinned: PmxAxPinnedContext;
|
|
37
|
+
focus: PmxAxFocusContext;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const AX_SOURCES = new Set<PmxAxSource>(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system']);
|
|
41
|
+
|
|
42
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
43
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeSource(value: unknown): PmxAxSource | null {
|
|
47
|
+
return typeof value === 'string' && AX_SOURCES.has(value as PmxAxSource)
|
|
48
|
+
? value as PmxAxSource
|
|
49
|
+
: null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeTimestamp(value: unknown): string | null {
|
|
53
|
+
if (typeof value !== 'string') return null;
|
|
54
|
+
const parsed = Date.parse(value);
|
|
55
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeNodeIds(value: unknown, validNodeIds?: Set<string>): string[] {
|
|
59
|
+
if (!Array.isArray(value)) return [];
|
|
60
|
+
const ids: string[] = [];
|
|
61
|
+
for (const item of value) {
|
|
62
|
+
if (typeof item !== 'string') continue;
|
|
63
|
+
if (validNodeIds && !validNodeIds.has(item)) continue;
|
|
64
|
+
if (!ids.includes(item)) ids.push(item);
|
|
65
|
+
}
|
|
66
|
+
return ids;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createEmptyAxFocusState(): PmxAxFocusState {
|
|
70
|
+
return {
|
|
71
|
+
nodeIds: [],
|
|
72
|
+
primaryNodeId: null,
|
|
73
|
+
updatedAt: null,
|
|
74
|
+
source: null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createEmptyAxState(): PmxAxState {
|
|
79
|
+
return {
|
|
80
|
+
version: 1,
|
|
81
|
+
focus: createEmptyAxFocusState(),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function normalizeAxFocusState(input: unknown, validNodeIds?: Set<string>): PmxAxFocusState {
|
|
86
|
+
if (!isRecord(input)) return createEmptyAxFocusState();
|
|
87
|
+
const nodeIds = normalizeNodeIds(input.nodeIds, validNodeIds);
|
|
88
|
+
const primaryNodeId = typeof input.primaryNodeId === 'string' && nodeIds.includes(input.primaryNodeId)
|
|
89
|
+
? input.primaryNodeId
|
|
90
|
+
: nodeIds[0] ?? null;
|
|
91
|
+
return {
|
|
92
|
+
nodeIds,
|
|
93
|
+
primaryNodeId,
|
|
94
|
+
updatedAt: normalizeTimestamp(input.updatedAt),
|
|
95
|
+
source: normalizeSource(input.source),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function normalizeAxState(input: unknown, validNodeIds?: Set<string>): PmxAxState {
|
|
100
|
+
if (!isRecord(input)) return createEmptyAxState();
|
|
101
|
+
return {
|
|
102
|
+
version: 1,
|
|
103
|
+
focus: normalizeAxFocusState(input.focus, validNodeIds),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function buildAxContext(input: {
|
|
108
|
+
layout: CanvasLayout;
|
|
109
|
+
pinned: PmxAxPinnedContext;
|
|
110
|
+
focus: PmxAxFocusState;
|
|
111
|
+
focusNodes: AgentContextNode[];
|
|
112
|
+
}): PmxAxContext {
|
|
113
|
+
return {
|
|
114
|
+
version: 1,
|
|
115
|
+
generatedAt: new Date().toISOString(),
|
|
116
|
+
surface: {
|
|
117
|
+
nodeCount: input.layout.nodes.length,
|
|
118
|
+
edgeCount: input.layout.edges.length,
|
|
119
|
+
},
|
|
120
|
+
pinned: input.pinned,
|
|
121
|
+
focus: {
|
|
122
|
+
...input.focus,
|
|
123
|
+
nodes: input.focusNodes,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function nodeSetFromLayout(nodes: CanvasNodeState[]): Set<string> {
|
|
129
|
+
return new Set(nodes.map((node) => node.id));
|
|
130
|
+
}
|