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
|
@@ -1107,10 +1107,13 @@ body,
|
|
|
1107
1107
|
position: absolute;
|
|
1108
1108
|
bottom: 0;
|
|
1109
1109
|
right: 0;
|
|
1110
|
-
width:
|
|
1111
|
-
height:
|
|
1110
|
+
width: 32px;
|
|
1111
|
+
height: 32px;
|
|
1112
|
+
background: rgba(0, 0, 0, 0.001);
|
|
1112
1113
|
cursor: nwse-resize;
|
|
1113
|
-
z-index:
|
|
1114
|
+
z-index: 30;
|
|
1115
|
+
pointer-events: auto;
|
|
1116
|
+
touch-action: none;
|
|
1114
1117
|
}
|
|
1115
1118
|
|
|
1116
1119
|
.canvas-node .node-resize-handle::after {
|
|
@@ -1130,6 +1133,36 @@ body,
|
|
|
1130
1133
|
opacity: 1;
|
|
1131
1134
|
}
|
|
1132
1135
|
|
|
1136
|
+
html.is-node-resizing,
|
|
1137
|
+
html.is-node-resizing * {
|
|
1138
|
+
cursor: nwse-resize !important;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
html.is-node-resizing .canvas-node {
|
|
1142
|
+
transition: box-shadow 0.15s ease !important;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
html.is-node-dragging .attention-field-layer {
|
|
1146
|
+
visibility: hidden;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
html.is-node-dragging,
|
|
1150
|
+
html.is-node-dragging * {
|
|
1151
|
+
cursor: grabbing !important;
|
|
1152
|
+
user-select: none !important;
|
|
1153
|
+
-webkit-user-select: none !important;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
html.is-node-dragging iframe,
|
|
1157
|
+
html.is-node-dragging .ext-app-preview-catcher {
|
|
1158
|
+
pointer-events: none !important;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
html.is-node-resizing iframe,
|
|
1162
|
+
html.is-node-resizing .ext-app-preview-catcher {
|
|
1163
|
+
pointer-events: none !important;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1133
1166
|
/* Pinned node indicator */
|
|
1134
1167
|
.canvas-node.pinned {
|
|
1135
1168
|
border-style: dashed;
|
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
|
+
}
|
package/src/server/canvas-db.ts
CHANGED
|
@@ -17,11 +17,18 @@ import type {
|
|
|
17
17
|
CanvasSnapshotListOptions,
|
|
18
18
|
ViewportState,
|
|
19
19
|
} from './canvas-state.js';
|
|
20
|
+
import { createEmptyAxState, normalizeAxState, type PmxAxState } from './ax-state.js';
|
|
20
21
|
|
|
21
22
|
// ── Schema ──────────────────────────────────────────────────────
|
|
22
23
|
|
|
23
24
|
const SCHEMA_VERSION = 1;
|
|
24
25
|
|
|
26
|
+
export type CanvasTheme = 'dark' | 'light' | 'high-contrast';
|
|
27
|
+
|
|
28
|
+
export function normalizeCanvasTheme(value: unknown, fallback: CanvasTheme = 'dark'): CanvasTheme {
|
|
29
|
+
return value === 'dark' || value === 'light' || value === 'high-contrast' ? value : fallback;
|
|
30
|
+
}
|
|
31
|
+
|
|
25
32
|
const SCHEMA_SQL = `
|
|
26
33
|
CREATE TABLE IF NOT EXISTS meta (
|
|
27
34
|
key TEXT PRIMARY KEY,
|
|
@@ -68,6 +75,11 @@ const SCHEMA_SQL = `
|
|
|
68
75
|
node_id TEXT PRIMARY KEY
|
|
69
76
|
);
|
|
70
77
|
|
|
78
|
+
CREATE TABLE IF NOT EXISTS ax_state (
|
|
79
|
+
key TEXT PRIMARY KEY,
|
|
80
|
+
value TEXT NOT NULL
|
|
81
|
+
);
|
|
82
|
+
|
|
71
83
|
CREATE TABLE IF NOT EXISTS snapshots (
|
|
72
84
|
id TEXT PRIMARY KEY,
|
|
73
85
|
name TEXT NOT NULL,
|
|
@@ -149,15 +161,26 @@ function normalizeSnapshotTimestamp(value: string | undefined): string | undefin
|
|
|
149
161
|
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined;
|
|
150
162
|
}
|
|
151
163
|
|
|
164
|
+
function parsePersistedAxState(raw: string | null | undefined): PmxAxState {
|
|
165
|
+
if (!raw) return createEmptyAxState();
|
|
166
|
+
try {
|
|
167
|
+
return normalizeAxState(JSON.parse(raw));
|
|
168
|
+
} catch {
|
|
169
|
+
return createEmptyAxState();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
152
173
|
// ── Persisted State Interface ───────────────────────────────────
|
|
153
174
|
|
|
154
175
|
export interface PersistedCanvasState {
|
|
155
176
|
version: number;
|
|
177
|
+
theme?: CanvasTheme;
|
|
156
178
|
viewport: ViewportState;
|
|
157
179
|
nodes: CanvasNodeState[];
|
|
158
180
|
edges: CanvasEdge[];
|
|
159
181
|
annotations?: CanvasAnnotation[];
|
|
160
182
|
contextPins: string[];
|
|
183
|
+
ax?: PmxAxState;
|
|
161
184
|
}
|
|
162
185
|
|
|
163
186
|
// ── Database Management ─────────────────────────────────────────
|
|
@@ -199,8 +222,10 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
|
|
|
199
222
|
db.run('DELETE FROM edges');
|
|
200
223
|
db.run('DELETE FROM annotations');
|
|
201
224
|
db.run('DELETE FROM context_pins');
|
|
225
|
+
db.run('DELETE FROM ax_state');
|
|
202
226
|
|
|
203
|
-
// Save viewport
|
|
227
|
+
// Save viewport and UI preferences
|
|
228
|
+
db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['theme', normalizeCanvasTheme(state.theme)]);
|
|
204
229
|
db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['viewport_x', String(state.viewport.x)]);
|
|
205
230
|
db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['viewport_y', String(state.viewport.y)]);
|
|
206
231
|
db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['viewport_scale', String(state.viewport.scale)]);
|
|
@@ -270,6 +295,8 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
|
|
|
270
295
|
for (const pinId of state.contextPins) {
|
|
271
296
|
insertPin.run(pinId);
|
|
272
297
|
}
|
|
298
|
+
|
|
299
|
+
db.run('INSERT INTO ax_state (key, value) VALUES (?, ?)', ['state', JSON.stringify(state.ax ?? createEmptyAxState())]);
|
|
273
300
|
});
|
|
274
301
|
|
|
275
302
|
transaction();
|
|
@@ -298,6 +325,8 @@ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
|
|
|
298
325
|
y: getMetaValue('viewport_y'),
|
|
299
326
|
scale: getMetaValue('viewport_scale') || 1,
|
|
300
327
|
};
|
|
328
|
+
const themeValue = db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get('theme')?.value;
|
|
329
|
+
const theme = themeValue ? normalizeCanvasTheme(themeValue) : undefined;
|
|
301
330
|
|
|
302
331
|
// Load nodes
|
|
303
332
|
interface NodeRow {
|
|
@@ -377,13 +406,17 @@ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
|
|
|
377
406
|
const pinRows = db.query<PinRow, []>('SELECT node_id FROM context_pins').all();
|
|
378
407
|
const contextPins = pinRows.map((row) => row.node_id);
|
|
379
408
|
|
|
409
|
+
const axRow = db.query<{ value: string }, [string]>('SELECT value FROM ax_state WHERE key = ?').get('state');
|
|
410
|
+
|
|
380
411
|
return {
|
|
381
412
|
version: 1,
|
|
413
|
+
theme,
|
|
382
414
|
viewport,
|
|
383
415
|
nodes,
|
|
384
416
|
edges,
|
|
385
417
|
annotations,
|
|
386
418
|
contextPins,
|
|
419
|
+
ax: parsePersistedAxState(axRow?.value),
|
|
387
420
|
};
|
|
388
421
|
}
|
|
389
422
|
|
|
@@ -405,6 +438,7 @@ export function saveSnapshotToDB(
|
|
|
405
438
|
db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [snapshot.id, 'viewport_x', String(state.viewport.x)]);
|
|
406
439
|
db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [snapshot.id, 'viewport_y', String(state.viewport.y)]);
|
|
407
440
|
db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [snapshot.id, 'viewport_scale', String(state.viewport.scale)]);
|
|
441
|
+
db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [snapshot.id, 'ax_state', JSON.stringify(state.ax ?? createEmptyAxState())]);
|
|
408
442
|
|
|
409
443
|
// Insert snapshot nodes
|
|
410
444
|
const insertNode = db.prepare(
|
|
@@ -616,6 +650,7 @@ export function loadSnapshotFromDB(
|
|
|
616
650
|
edges,
|
|
617
651
|
annotations,
|
|
618
652
|
contextPins,
|
|
653
|
+
ax: parsePersistedAxState(metaMap.get('ax_state')),
|
|
619
654
|
},
|
|
620
655
|
};
|
|
621
656
|
}
|