pmx-canvas 0.1.20 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +150 -0
- package/dist/canvas/global.css +71 -0
- package/dist/canvas/index.js +94 -60
- package/dist/types/client/nodes/HtmlNode.d.ts +12 -1
- package/dist/types/client/types.d.ts +1 -1
- package/dist/types/server/canvas-serialization.d.ts +1 -0
- package/dist/types/server/html-node-summary.d.ts +2 -0
- package/dist/types/server/html-primitives.d.ts +9 -1
- package/dist/types/server/index.d.ts +8 -1
- package/docs/http-api.md +1 -1
- package/docs/mcp.md +4 -0
- package/docs/node-types.md +27 -5
- package/docs/screenshot.png +0 -0
- package/docs/sdk.md +1 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +10 -4
- package/skills/pmx-canvas/references/html-primitives.md +132 -0
- package/src/cli/agent.ts +34 -1
- package/src/cli/index.ts +3 -1
- package/src/client/App.tsx +1 -1
- package/src/client/canvas/CommandPalette.tsx +1 -1
- package/src/client/canvas/ExpandedNodeOverlay.tsx +115 -2
- package/src/client/canvas/auto-fit.ts +5 -1
- package/src/client/nodes/HtmlNode.tsx +125 -13
- package/src/client/state/sse-bridge.ts +1 -1
- package/src/client/theme/global.css +71 -0
- package/src/mcp/canvas-access.ts +31 -1
- package/src/mcp/server.ts +17 -3
- package/src/server/agent-context.ts +23 -1
- package/src/server/canvas-operations.ts +18 -5
- package/src/server/canvas-provenance.ts +8 -6
- package/src/server/canvas-schema.ts +11 -0
- package/src/server/canvas-serialization.ts +36 -5
- package/src/server/html-node-summary.ts +141 -0
- package/src/server/html-primitives.ts +328 -8
- package/src/server/index.ts +22 -3
- package/src/server/server.ts +27 -9
- package/src/server/spatial-analysis.ts +4 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import { normalizeHtmlNodeSemanticData } from './html-node-summary.js';
|
|
2
3
|
|
|
3
4
|
export type CanvasNodeType =
|
|
4
5
|
| 'markdown'
|
|
@@ -228,17 +229,18 @@ export function normalizeCanvasNodeData<T extends Record<string, unknown>>(
|
|
|
228
229
|
nodeType: CanvasNodeType,
|
|
229
230
|
data: T,
|
|
230
231
|
): T {
|
|
231
|
-
const
|
|
232
|
-
const
|
|
232
|
+
const semanticData = nodeType === 'html' ? normalizeHtmlNodeSemanticData(data) : data;
|
|
233
|
+
const existing = normalizeExistingProvenance(semanticData.provenance);
|
|
234
|
+
const inferred = inferCanvasNodeProvenance(nodeType, semanticData);
|
|
233
235
|
const provenance = mergeProvenance(existing, inferred);
|
|
234
236
|
|
|
235
237
|
if (provenance) {
|
|
236
|
-
return { ...
|
|
238
|
+
return { ...semanticData, provenance } as T;
|
|
237
239
|
}
|
|
238
|
-
if ('provenance' in
|
|
239
|
-
const nextData = { ...
|
|
240
|
+
if ('provenance' in semanticData) {
|
|
241
|
+
const nextData = { ...semanticData };
|
|
240
242
|
delete nextData.provenance;
|
|
241
243
|
return nextData as T;
|
|
242
244
|
}
|
|
243
|
-
return
|
|
245
|
+
return semanticData;
|
|
244
246
|
}
|
|
@@ -243,6 +243,12 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
243
243
|
mcpTool: 'canvas_add_html_node',
|
|
244
244
|
fields: [
|
|
245
245
|
{ name: 'html', type: 'string', required: false, description: 'HTML document or fragment rendered in the sandboxed iframe.', aliases: ['content', 'stdin'] },
|
|
246
|
+
{ name: 'summary', type: 'string', required: false, description: 'Explicit agent-readable summary. If omitted, PMX derives one from visible HTML text.' },
|
|
247
|
+
{ name: 'agentSummary', type: 'string', required: false, description: 'Explicit semantic sidecar used by search, pinned context, and spatial context.', aliases: ['agent-summary'] },
|
|
248
|
+
{ name: 'embeddedNodeIds', type: 'string[]', required: false, description: 'Canvas node IDs represented or iframe-embedded by this HTML surface.', aliases: ['embedded-node-id'] },
|
|
249
|
+
{ name: 'embeddedUrls', type: 'string[]', required: false, description: 'URLs represented or iframe-embedded by this HTML surface.', aliases: ['embedded-url'] },
|
|
250
|
+
{ name: 'presentation', type: 'boolean', required: false, description: 'Marks this HTML surface as a fullscreen presentation/deck.' },
|
|
251
|
+
{ name: 'slideTitles', type: 'string[]', required: false, description: 'Agent-readable slide titles for presentation HTML.', aliases: ['slide-title'] },
|
|
246
252
|
{ name: 'primitive', type: 'HtmlPrimitiveKind', required: false, description: 'Generate HTML from a built-in communication primitive instead of passing raw HTML.', aliases: ['kind'] },
|
|
247
253
|
{ name: 'data', type: 'record<string, unknown>', required: false, description: 'Primitive data when --primitive is used, or arbitrary node metadata.' },
|
|
248
254
|
{ name: 'title', type: 'string', required: false, description: 'Optional node title.' },
|
|
@@ -259,6 +265,9 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
259
265
|
},
|
|
260
266
|
notes: [
|
|
261
267
|
'The CLI accepts --content as an alias and stores it as data.html so the renderer can load it.',
|
|
268
|
+
'Normal html nodes are the default. Presentation mode is opt-in via presentation:true or the presentation primitive.',
|
|
269
|
+
'HTML nodes persist data.contentSummary and data.agentSummary so agents can understand rich visual HTML without parsing the full iframe payload.',
|
|
270
|
+
'Only presentation-marked HTML nodes expose a browser Present button for fullscreen review; use the presentation primitive for PowerPoint-like decks.',
|
|
262
271
|
'Use `primitive` / `kind` with `data` to create reusable agent communication artifacts such as choice grids, plans, review sheets, explainers, and editors.',
|
|
263
272
|
'HTML runs in a sandboxed iframe without same-origin access to the canvas host.',
|
|
264
273
|
],
|
|
@@ -291,6 +300,8 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
291
300
|
},
|
|
292
301
|
notes: [
|
|
293
302
|
'HTTP callers may POST { type: "html-primitive", kind, data } or { type: "html", primitive: kind, data }; both create a normal html node with primitive metadata.',
|
|
303
|
+
'Use kind "presentation" only when a PowerPoint-like deck is requested; created nodes persist presentation, slideCount, slideTitles, and optional presentationTheme metadata for agents.',
|
|
304
|
+
'Presentation primitive data supports theme: "canvas" | "midnight" | "paper" | "aurora" or a custom color object with bg, panel, surface, border, text, textSecondary, textMuted, accent, and colorScheme.',
|
|
294
305
|
'Interactive editor primitives include copy/export controls so the human can send edited state back to the agent.',
|
|
295
306
|
],
|
|
296
307
|
},
|
|
@@ -56,6 +56,13 @@ interface ExternalMcpAppHtmlSummary {
|
|
|
56
56
|
sha256: string;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
interface FileContentSummary {
|
|
60
|
+
omitted: 'file-content';
|
|
61
|
+
bytes: number;
|
|
62
|
+
lineCount: number;
|
|
63
|
+
sha256: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
59
66
|
function pickString(value: unknown): string | null {
|
|
60
67
|
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
61
68
|
}
|
|
@@ -76,10 +83,14 @@ export function getCanvasNodeTitle(node: CanvasNodeState): string | null {
|
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
export function getCanvasNodeContent(node: CanvasNodeState): string | null {
|
|
79
|
-
if (node.type === 'html'
|
|
80
|
-
const primitive = node.data.htmlPrimitive;
|
|
86
|
+
if (node.type === 'html') {
|
|
87
|
+
const primitive = typeof node.data.htmlPrimitive === 'string' ? node.data.htmlPrimitive : null;
|
|
81
88
|
const description = pickString(node.data.description);
|
|
82
|
-
return
|
|
89
|
+
return pickString(node.data.agentSummary)
|
|
90
|
+
?? pickString(node.data.contentSummary)
|
|
91
|
+
?? (primitive
|
|
92
|
+
? (description ? `${primitive}: ${description}` : primitive)
|
|
93
|
+
: null);
|
|
83
94
|
}
|
|
84
95
|
return pickString(node.data.content)
|
|
85
96
|
?? pickString(node.data.fileContent)
|
|
@@ -92,12 +103,13 @@ export function getCanvasNodeContent(node: CanvasNodeState): string | null {
|
|
|
92
103
|
|
|
93
104
|
export function serializeCanvasNode(node: CanvasNodeState): SerializedCanvasNode {
|
|
94
105
|
const data = normalizeCanvasNodeData(node.type, node.data);
|
|
106
|
+
const normalizedNode = { ...node, data };
|
|
95
107
|
return {
|
|
96
108
|
...node,
|
|
97
109
|
data,
|
|
98
110
|
kind: getCanvasNodeKind(node, data),
|
|
99
|
-
title: getCanvasNodeTitle(
|
|
100
|
-
content: getCanvasNodeContent(
|
|
111
|
+
title: getCanvasNodeTitle(normalizedNode),
|
|
112
|
+
content: getCanvasNodeContent(normalizedNode),
|
|
101
113
|
path: pickString(data.path),
|
|
102
114
|
url: pickString(data.url),
|
|
103
115
|
provenance: pickProvenance(data.provenance),
|
|
@@ -137,6 +149,25 @@ export function serializeCanvasNodeForAgent(node: CanvasNodeState): SerializedCa
|
|
|
137
149
|
};
|
|
138
150
|
}
|
|
139
151
|
|
|
152
|
+
export function serializeCanvasNodeCompact(node: CanvasNodeState): SerializedCanvasNode {
|
|
153
|
+
const serialized = serializeCanvasNode(node);
|
|
154
|
+
if (serialized.type !== 'file' || typeof serialized.data.fileContent !== 'string') return serialized;
|
|
155
|
+
const fileContent = serialized.data.fileContent;
|
|
156
|
+
return {
|
|
157
|
+
...serialized,
|
|
158
|
+
content: serialized.path,
|
|
159
|
+
data: {
|
|
160
|
+
...serialized.data,
|
|
161
|
+
fileContent: {
|
|
162
|
+
omitted: 'file-content',
|
|
163
|
+
bytes: Buffer.byteLength(fileContent, 'utf-8'),
|
|
164
|
+
lineCount: fileContent.split('\n').length,
|
|
165
|
+
sha256: createHash('sha256').update(fileContent).digest('hex'),
|
|
166
|
+
} satisfies FileContentSummary,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
140
171
|
function summarizeBlobValue(value: unknown): unknown {
|
|
141
172
|
if (!canvasState.isBlobReference(value)) return value;
|
|
142
173
|
return {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
const HTML_CONTENT_SUMMARY_MAX_LENGTH = 900;
|
|
2
|
+
const HTML_AGENT_SUMMARY_MAX_LENGTH = 1200;
|
|
3
|
+
const HTML_REFERENCE_LIMIT = 12;
|
|
4
|
+
|
|
5
|
+
function pickString(value: unknown): string | null {
|
|
6
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function strings(value: unknown): string[] {
|
|
10
|
+
return Array.isArray(value)
|
|
11
|
+
? value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
|
12
|
+
: [];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function truncateText(value: string, maxLength: number): string {
|
|
16
|
+
if (value.length <= maxLength) return value;
|
|
17
|
+
return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeWhitespace(value: string): string {
|
|
21
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function decodeHtmlEntities(value: string): string {
|
|
25
|
+
const named: Record<string, string> = {
|
|
26
|
+
amp: '&',
|
|
27
|
+
gt: '>',
|
|
28
|
+
lt: '<',
|
|
29
|
+
nbsp: ' ',
|
|
30
|
+
quot: '"',
|
|
31
|
+
apos: "'",
|
|
32
|
+
};
|
|
33
|
+
return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, entity) => {
|
|
34
|
+
const lower = entity.toLowerCase();
|
|
35
|
+
if (lower.startsWith('#x')) {
|
|
36
|
+
const codePoint = Number.parseInt(lower.slice(2), 16);
|
|
37
|
+
return Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : match;
|
|
38
|
+
}
|
|
39
|
+
if (lower.startsWith('#')) {
|
|
40
|
+
const codePoint = Number.parseInt(lower.slice(1), 10);
|
|
41
|
+
return Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : match;
|
|
42
|
+
}
|
|
43
|
+
return named[lower] ?? match;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function summarizeHtmlText(html: string): string | null {
|
|
48
|
+
const withoutNoise = html
|
|
49
|
+
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
50
|
+
.replace(/<script\b[\s\S]*?<\/script>/gi, ' ')
|
|
51
|
+
.replace(/<style\b[\s\S]*?<\/style>/gi, ' ')
|
|
52
|
+
.replace(/<noscript\b[\s\S]*?<\/noscript>/gi, ' ');
|
|
53
|
+
const text = withoutNoise
|
|
54
|
+
.replace(/<(?:h[1-6]|p|li|br|div|section|article|header|footer|main|aside|summary|figcaption|blockquote|tr|td|th)\b[^>]*>/gi, '\n')
|
|
55
|
+
.replace(/<[^>]+>/g, ' ');
|
|
56
|
+
const normalized = normalizeWhitespace(decodeHtmlEntities(text));
|
|
57
|
+
return normalized.length > 0 ? truncateText(normalized, HTML_CONTENT_SUMMARY_MAX_LENGTH) : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function uniqueLimited(values: string[]): string[] {
|
|
61
|
+
const seen = new Set<string>();
|
|
62
|
+
const unique: string[] = [];
|
|
63
|
+
for (const value of values) {
|
|
64
|
+
const trimmed = value.trim();
|
|
65
|
+
if (!trimmed || seen.has(trimmed)) continue;
|
|
66
|
+
seen.add(trimmed);
|
|
67
|
+
unique.push(trimmed);
|
|
68
|
+
if (unique.length >= HTML_REFERENCE_LIMIT) break;
|
|
69
|
+
}
|
|
70
|
+
return unique;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function extractHtmlNodeIds(html: string): string[] {
|
|
74
|
+
const ids: string[] = [];
|
|
75
|
+
for (const match of html.matchAll(/\b(?:node|graph|json-render|web-artifact|mcp-app|group)-[a-z0-9-]+\b/gi)) {
|
|
76
|
+
ids.push(match[0]);
|
|
77
|
+
}
|
|
78
|
+
return uniqueLimited(ids);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function extractHtmlUrls(html: string): string[] {
|
|
82
|
+
const urls: string[] = [];
|
|
83
|
+
for (const match of html.matchAll(/\b(?:src|href)\s*=\s*["']([^"']+)["']/gi)) {
|
|
84
|
+
const url = match[1]?.trim();
|
|
85
|
+
if (!url) continue;
|
|
86
|
+
if (/^(?:https?:)?\/\//i.test(url) || url.startsWith('/') || url.startsWith('ui://')) {
|
|
87
|
+
urls.push(url);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return uniqueLimited(urls);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function joinSummaryParts(parts: string[]): string | null {
|
|
94
|
+
const summary = parts
|
|
95
|
+
.map((part) => part.trim())
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.filter((part, index, all) => all.findIndex((candidate) => candidate === part) === index)
|
|
98
|
+
.join('\n');
|
|
99
|
+
return summary ? truncateText(summary, HTML_AGENT_SUMMARY_MAX_LENGTH) : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function normalizeHtmlNodeSemanticData<T extends Record<string, unknown>>(data: T): T {
|
|
103
|
+
const {
|
|
104
|
+
agentSummary: _agentSummary,
|
|
105
|
+
contentSummary: _contentSummary,
|
|
106
|
+
embeddedNodeIds: _embeddedNodeIds,
|
|
107
|
+
embeddedUrls: _embeddedUrls,
|
|
108
|
+
embeddedNodeId: _embeddedNodeId,
|
|
109
|
+
embeddedGraphId: _embeddedGraphId,
|
|
110
|
+
sourceNodeId: _sourceNodeId,
|
|
111
|
+
...base
|
|
112
|
+
} = data;
|
|
113
|
+
|
|
114
|
+
const html = pickString(base.html);
|
|
115
|
+
const explicitSummary = pickString(base.summary) ?? pickString(base.description);
|
|
116
|
+
const primitive = pickString(base.htmlPrimitive);
|
|
117
|
+
const contentSummary = html ? summarizeHtmlText(html) : null;
|
|
118
|
+
const explicitNodeIds = [
|
|
119
|
+
...strings(data.embeddedNodeIds),
|
|
120
|
+
pickString(data.embeddedNodeId),
|
|
121
|
+
pickString(data.embeddedGraphId),
|
|
122
|
+
pickString(data.sourceNodeId),
|
|
123
|
+
].filter((value): value is string => value !== null);
|
|
124
|
+
const embeddedNodeIds = uniqueLimited([...explicitNodeIds, ...(html ? extractHtmlNodeIds(html) : [])]);
|
|
125
|
+
const embeddedUrls = uniqueLimited([...strings(data.embeddedUrls), ...(html ? extractHtmlUrls(html) : [])]);
|
|
126
|
+
const agentSummary = pickString(data.agentSummary) ?? joinSummaryParts([
|
|
127
|
+
primitive ? `HTML primitive: ${primitive}` : '',
|
|
128
|
+
explicitSummary ?? '',
|
|
129
|
+
contentSummary ?? '',
|
|
130
|
+
embeddedNodeIds.length > 0 ? `Embedded canvas nodes: ${embeddedNodeIds.join(', ')}` : '',
|
|
131
|
+
embeddedUrls.length > 0 ? `Embedded URLs: ${embeddedUrls.join(', ')}` : '',
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
...base,
|
|
136
|
+
...(contentSummary ? { contentSummary } : {}),
|
|
137
|
+
...(agentSummary ? { agentSummary } : {}),
|
|
138
|
+
...(embeddedNodeIds.length > 0 ? { embeddedNodeIds } : {}),
|
|
139
|
+
...(embeddedUrls.length > 0 ? { embeddedUrls } : {}),
|
|
140
|
+
} as T;
|
|
141
|
+
}
|