pmx-canvas 0.1.20 → 0.1.21
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 +85 -0
- package/dist/canvas/global.css +88 -0
- package/dist/canvas/index.js +87 -53
- package/dist/types/client/nodes/HtmlNode.d.ts +12 -1
- package/dist/types/client/types.d.ts +1 -1
- 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 +9 -0
- package/src/cli/index.ts +1 -1
- package/src/client/App.tsx +1 -1
- package/src/client/canvas/CommandPalette.tsx +1 -1
- package/src/client/canvas/ExpandedNodeOverlay.tsx +105 -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 +88 -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 +10 -2
- package/src/server/canvas-provenance.ts +8 -6
- package/src/server/canvas-schema.ts +11 -0
- package/src/server/canvas-serialization.ts +10 -5
- package/src/server/html-node-summary.ts +141 -0
- package/src/server/html-primitives.ts +318 -8
- package/src/server/index.ts +22 -3
- package/src/server/server.ts +17 -4
- package/src/server/spatial-analysis.ts +4 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useMemo } from 'preact/hooks';
|
|
1
|
+
import { useEffect, useMemo, useRef } from 'preact/hooks';
|
|
2
|
+
import { canvasTheme } from '../state/canvas-store';
|
|
2
3
|
import { getCanvasTokens } from '../theme/tokens';
|
|
3
4
|
import type { CanvasNodeState } from '../types';
|
|
4
5
|
|
|
@@ -95,28 +96,135 @@ function buildThemeStyleBlock(): string {
|
|
|
95
96
|
* a `<head>`, inject at the top of head; otherwise wrap the content in a full
|
|
96
97
|
* document. Returns a complete HTML string suitable for `srcdoc`.
|
|
97
98
|
*/
|
|
98
|
-
function
|
|
99
|
-
const
|
|
99
|
+
function buildPresentationEscapeBridge(exitToken?: string): string {
|
|
100
|
+
const token = JSON.stringify(exitToken ?? '');
|
|
101
|
+
return `<script data-pmx-canvas-presentation-bridge>
|
|
102
|
+
const PMX_CANVAS_PRESENTATION_EXIT_TOKEN = ${token};
|
|
103
|
+
document.addEventListener('keydown', (event) => {
|
|
104
|
+
if (event.key === 'Escape') {
|
|
105
|
+
window.parent.postMessage({ source: 'pmx-canvas-html-node', type: 'presentation-exit', token: PMX_CANVAS_PRESENTATION_EXIT_TOKEN }, '*');
|
|
106
|
+
}
|
|
107
|
+
}, true);
|
|
108
|
+
window.addEventListener('message', (event) => {
|
|
109
|
+
const message = event.data;
|
|
110
|
+
if (!message || message.source !== 'pmx-canvas-html-node' || message.type !== 'presentation-key' || message.token !== PMX_CANVAS_PRESENTATION_EXIT_TOKEN) return;
|
|
111
|
+
if (typeof message.key !== 'string') return;
|
|
112
|
+
if (typeof window.PMX_CANVAS_PRESENTATION_HANDLE_KEY === 'function') {
|
|
113
|
+
window.PMX_CANVAS_PRESENTATION_HANDLE_KEY(message.key);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
document.dispatchEvent(new CustomEvent('pmx-presentation-key', { detail: { key: message.key }, bubbles: true, cancelable: true }));
|
|
117
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: message.key, bubbles: true, cancelable: true }));
|
|
118
|
+
});
|
|
119
|
+
</script>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildThemeBridge(themeToken: string): string {
|
|
123
|
+
const token = JSON.stringify(themeToken);
|
|
124
|
+
return `<script data-pmx-canvas-theme-bridge>
|
|
125
|
+
const PMX_CANVAS_THEME_TOKEN = ${token};
|
|
126
|
+
window.addEventListener('message', (event) => {
|
|
127
|
+
const message = event.data;
|
|
128
|
+
if (!message || message.source !== 'pmx-canvas-html-node' || message.type !== 'theme-update' || message.token !== PMX_CANVAS_THEME_TOKEN) return;
|
|
129
|
+
if (typeof message.css !== 'string' || typeof message.theme !== 'string') return;
|
|
130
|
+
let style = document.querySelector('style[data-pmx-canvas-theme]');
|
|
131
|
+
if (!style) {
|
|
132
|
+
style = document.createElement('style');
|
|
133
|
+
style.setAttribute('data-pmx-canvas-theme', '');
|
|
134
|
+
document.head.prepend(style);
|
|
135
|
+
}
|
|
136
|
+
style.textContent = message.css;
|
|
137
|
+
document.documentElement.setAttribute('data-pmx-canvas-theme', message.theme);
|
|
138
|
+
document.documentElement.setAttribute('data-theme', message.theme);
|
|
139
|
+
});
|
|
140
|
+
</script>`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function injectIntoHead(html: string, content: string): string {
|
|
144
|
+
if (/<head[\s>]/i.test(html)) {
|
|
145
|
+
return html.replace(/<head([^>]*)>/i, `<head$1>${content}`);
|
|
146
|
+
}
|
|
147
|
+
if (/<html[\s>]/i.test(html)) {
|
|
148
|
+
return html.replace(/<html([^>]*)>/i, `<html$1><head>${content}</head>`);
|
|
149
|
+
}
|
|
150
|
+
return html;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function buildSrcDoc(userHtml: string, options: { presentation?: boolean; presentationExitToken?: string; themeToken: string; themeCss: string; theme: string }): string {
|
|
154
|
+
const styleBlock = `<style data-pmx-canvas-theme>${options.themeCss}</style>`;
|
|
155
|
+
const themeBridge = buildThemeBridge(options.themeToken);
|
|
156
|
+
const presentationBridge = options.presentation ? buildPresentationEscapeBridge(options.presentationExitToken) : '';
|
|
157
|
+
const injectedHeadContent = `${styleBlock}${themeBridge}${presentationBridge}`;
|
|
158
|
+
const presentationAttr = options.presentation ? ' data-pmx-presentation-mode="present"' : '';
|
|
100
159
|
const trimmed = userHtml.trim();
|
|
101
160
|
const isFullDoc = /<html[\s>]/i.test(trimmed);
|
|
102
161
|
if (isFullDoc) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
// Has <html> but no <head> — inject one.
|
|
107
|
-
return trimmed.replace(/<html([^>]*)>/i, `<html$1><head>${styleBlock}</head>`);
|
|
162
|
+
const withTheme = trimmed.replace(/<html([^>]*)>/i, `<html$1 data-pmx-canvas-theme="${options.theme}" data-theme="${options.theme}"${presentationAttr}>`);
|
|
163
|
+
return injectIntoHead(withTheme, injectedHeadContent);
|
|
108
164
|
}
|
|
109
165
|
// Fragment — wrap in full document.
|
|
110
|
-
return `<!doctype html><html><head><meta charset="utf-8">${
|
|
166
|
+
return `<!doctype html><html data-pmx-canvas-theme="${options.theme}" data-theme="${options.theme}"${presentationAttr}><head><meta charset="utf-8">${injectedHeadContent}</head><body>${userHtml}</body></html>`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function createHtmlNodeSrcDocForTest(userHtml: string, options: { theme: string; themeCss: string; themeToken?: string; presentation?: boolean; presentationExitToken?: string }): string {
|
|
170
|
+
return buildSrcDoc(userHtml, {
|
|
171
|
+
themeToken: options.themeToken ?? 'test-theme-token',
|
|
172
|
+
theme: options.theme,
|
|
173
|
+
themeCss: options.themeCss,
|
|
174
|
+
presentation: options.presentation,
|
|
175
|
+
presentationExitToken: options.presentationExitToken,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function shouldShowPresentationControls(node: CanvasNodeState): boolean {
|
|
180
|
+
return node.type === 'html' && node.data.presentation === true;
|
|
111
181
|
}
|
|
112
182
|
|
|
113
|
-
export function HtmlNode({
|
|
183
|
+
export function HtmlNode({
|
|
184
|
+
node,
|
|
185
|
+
expanded = false,
|
|
186
|
+
presentation = false,
|
|
187
|
+
presentationExitToken,
|
|
188
|
+
autoFocus = false,
|
|
189
|
+
}: { node: CanvasNodeState; expanded?: boolean; presentation?: boolean; presentationExitToken?: string; autoFocus?: boolean }) {
|
|
190
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
191
|
+
const theme = canvasTheme.value;
|
|
192
|
+
const themeToken = useMemo(() => `theme-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, []);
|
|
193
|
+
const themeCss = useMemo(() => buildThemeStyleBlock(), [theme]);
|
|
114
194
|
const html = typeof node.data.html === 'string'
|
|
115
195
|
? node.data.html
|
|
116
196
|
: typeof node.data.content === 'string'
|
|
117
197
|
? node.data.content
|
|
118
198
|
: '';
|
|
119
|
-
const srcDoc = useMemo(() => (html ? buildSrcDoc(html) : ''), [html]);
|
|
199
|
+
const srcDoc = useMemo(() => (html ? buildSrcDoc(html, { presentation, presentationExitToken, themeToken, themeCss, theme }) : ''), [html, presentation, presentationExitToken, themeToken]);
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
iframeRef.current?.contentWindow?.postMessage({
|
|
203
|
+
source: 'pmx-canvas-html-node',
|
|
204
|
+
type: 'theme-update',
|
|
205
|
+
token: themeToken,
|
|
206
|
+
theme,
|
|
207
|
+
css: themeCss,
|
|
208
|
+
}, '*');
|
|
209
|
+
if (autoFocus) iframeRef.current?.focus();
|
|
210
|
+
}, [theme, themeCss, themeToken]);
|
|
211
|
+
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (!autoFocus) return;
|
|
214
|
+
const id = window.setTimeout(() => iframeRef.current?.focus(), 0);
|
|
215
|
+
return () => window.clearTimeout(id);
|
|
216
|
+
}, [autoFocus, srcDoc]);
|
|
217
|
+
|
|
218
|
+
const handleFrameLoad = () => {
|
|
219
|
+
iframeRef.current?.contentWindow?.postMessage({
|
|
220
|
+
source: 'pmx-canvas-html-node',
|
|
221
|
+
type: 'theme-update',
|
|
222
|
+
token: themeToken,
|
|
223
|
+
theme,
|
|
224
|
+
css: themeCss,
|
|
225
|
+
}, '*');
|
|
226
|
+
if (autoFocus) iframeRef.current?.focus();
|
|
227
|
+
};
|
|
120
228
|
|
|
121
229
|
if (!html) {
|
|
122
230
|
return (
|
|
@@ -134,16 +242,20 @@ export function HtmlNode({ node, expanded = false }: { node: CanvasNodeState; ex
|
|
|
134
242
|
// arbitrary author code runs inside this exact sandbox.
|
|
135
243
|
return (
|
|
136
244
|
<iframe
|
|
245
|
+
ref={iframeRef}
|
|
246
|
+
class={presentation ? 'html-node-frame html-node-frame-presentation' : 'html-node-frame'}
|
|
137
247
|
title={typeof node.data.title === 'string' ? node.data.title : 'HTML node'}
|
|
138
248
|
sandbox="allow-scripts"
|
|
139
249
|
srcdoc={srcDoc}
|
|
250
|
+
tabIndex={autoFocus ? 0 : undefined}
|
|
251
|
+
onLoad={handleFrameLoad}
|
|
140
252
|
style={{
|
|
141
253
|
width: '100%',
|
|
142
254
|
height: '100%',
|
|
143
|
-
minHeight: expanded ? '70vh' : '300px',
|
|
255
|
+
minHeight: presentation ? 0 : expanded ? '70vh' : '300px',
|
|
144
256
|
border: 'none',
|
|
145
257
|
background: 'var(--c-bg)',
|
|
146
|
-
borderRadius: '6px',
|
|
258
|
+
borderRadius: presentation ? '18px' : '6px',
|
|
147
259
|
display: 'block',
|
|
148
260
|
}}
|
|
149
261
|
/>
|
|
@@ -291,9 +291,9 @@ function ensureLedgerNode(summary: Record<string, unknown>): void {
|
|
|
291
291
|
function applyCanvasTheme(theme: string): void {
|
|
292
292
|
const valid = theme === 'dark' || theme === 'light' || theme === 'high-contrast';
|
|
293
293
|
if (!valid || canvasTheme.value === theme) return;
|
|
294
|
-
canvasTheme.value = theme;
|
|
295
294
|
document.documentElement.setAttribute('data-theme', theme);
|
|
296
295
|
invalidateTokenCache();
|
|
296
|
+
canvasTheme.value = theme;
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
function isCanvasNodeType(value: unknown): value is CanvasNodeState['type'] {
|
|
@@ -2438,6 +2438,18 @@ body,
|
|
|
2438
2438
|
border-color: var(--c-muted);
|
|
2439
2439
|
}
|
|
2440
2440
|
|
|
2441
|
+
.expanded-action-btn.expanded-action-primary {
|
|
2442
|
+
background: var(--c-accent-12);
|
|
2443
|
+
border-color: var(--c-accent-30);
|
|
2444
|
+
color: var(--c-accent);
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
.expanded-action-btn.expanded-action-primary:hover {
|
|
2448
|
+
background: var(--c-accent-25);
|
|
2449
|
+
border-color: var(--c-accent);
|
|
2450
|
+
color: var(--c-text);
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2441
2453
|
.expanded-action-btn.expanded-action-active {
|
|
2442
2454
|
background: var(--c-warn-12);
|
|
2443
2455
|
border-color: var(--c-warn-30);
|
|
@@ -2455,6 +2467,82 @@ body,
|
|
|
2455
2467
|
padding: 0 4px;
|
|
2456
2468
|
}
|
|
2457
2469
|
|
|
2470
|
+
.html-presentation-overlay {
|
|
2471
|
+
position: fixed;
|
|
2472
|
+
inset: 0;
|
|
2473
|
+
z-index: 10050;
|
|
2474
|
+
display: flex;
|
|
2475
|
+
flex-direction: column;
|
|
2476
|
+
gap: 14px;
|
|
2477
|
+
padding: clamp(12px, 2vw, 28px);
|
|
2478
|
+
background:
|
|
2479
|
+
radial-gradient(circle at top left, var(--c-accent-25), transparent 36rem),
|
|
2480
|
+
rgba(3, 7, 18, 0.96);
|
|
2481
|
+
color: var(--c-text);
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
.html-presentation-toolbar {
|
|
2485
|
+
display: flex;
|
|
2486
|
+
align-items: center;
|
|
2487
|
+
justify-content: space-between;
|
|
2488
|
+
gap: 16px;
|
|
2489
|
+
flex-shrink: 0;
|
|
2490
|
+
padding: 10px 12px;
|
|
2491
|
+
border: 1px solid var(--c-line);
|
|
2492
|
+
border-radius: 16px;
|
|
2493
|
+
background: var(--c-panel-glass);
|
|
2494
|
+
box-shadow: 0 18px 50px var(--c-shadow-heavy);
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
.html-presentation-kicker {
|
|
2498
|
+
color: var(--c-accent);
|
|
2499
|
+
font-size: 10px;
|
|
2500
|
+
font-weight: 800;
|
|
2501
|
+
letter-spacing: 0.14em;
|
|
2502
|
+
text-transform: uppercase;
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
.html-presentation-title {
|
|
2506
|
+
max-width: min(72vw, 900px);
|
|
2507
|
+
overflow: hidden;
|
|
2508
|
+
color: var(--c-text);
|
|
2509
|
+
font-size: 14px;
|
|
2510
|
+
font-weight: 700;
|
|
2511
|
+
text-overflow: ellipsis;
|
|
2512
|
+
white-space: nowrap;
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
.html-presentation-exit {
|
|
2516
|
+
flex-shrink: 0;
|
|
2517
|
+
padding: 8px 12px;
|
|
2518
|
+
border: 1px solid var(--c-line);
|
|
2519
|
+
border-radius: 999px;
|
|
2520
|
+
background: var(--c-panel-soft);
|
|
2521
|
+
color: var(--c-text-soft);
|
|
2522
|
+
cursor: pointer;
|
|
2523
|
+
font: 600 12px/1 var(--font);
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
.html-presentation-exit:hover {
|
|
2527
|
+
border-color: var(--c-accent);
|
|
2528
|
+
color: var(--c-text);
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
.html-presentation-stage {
|
|
2532
|
+
flex: 1;
|
|
2533
|
+
min-height: 0;
|
|
2534
|
+
display: flex;
|
|
2535
|
+
border-radius: 22px;
|
|
2536
|
+
background: var(--c-bg);
|
|
2537
|
+
box-shadow: 0 24px 90px rgba(0, 0, 0, 0.55);
|
|
2538
|
+
overflow: hidden;
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
.html-node-frame-presentation {
|
|
2542
|
+
flex: 1;
|
|
2543
|
+
min-height: 0;
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2458
2546
|
/* ── Context pin button on node title bar ────────────────────── */
|
|
2459
2547
|
.node-controls .ctx-pin-btn {
|
|
2460
2548
|
color: var(--c-muted);
|
package/src/mcp/canvas-access.ts
CHANGED
|
@@ -442,7 +442,37 @@ class RemoteCanvasAccess implements CanvasAccess {
|
|
|
442
442
|
}
|
|
443
443
|
|
|
444
444
|
async addHtmlNode(input: AddHtmlNodeInput): Promise<string> {
|
|
445
|
-
|
|
445
|
+
const {
|
|
446
|
+
summary,
|
|
447
|
+
agentSummary,
|
|
448
|
+
description,
|
|
449
|
+
presentation,
|
|
450
|
+
slideTitles,
|
|
451
|
+
embeddedNodeIds,
|
|
452
|
+
embeddedUrls,
|
|
453
|
+
...rest
|
|
454
|
+
} = input as AddHtmlNodeInput & {
|
|
455
|
+
summary?: string;
|
|
456
|
+
agentSummary?: string;
|
|
457
|
+
description?: string;
|
|
458
|
+
presentation?: boolean;
|
|
459
|
+
slideTitles?: string[];
|
|
460
|
+
embeddedNodeIds?: string[];
|
|
461
|
+
embeddedUrls?: string[];
|
|
462
|
+
};
|
|
463
|
+
return await this.requestNodeId('POST', '/api/canvas/node', {
|
|
464
|
+
type: 'html',
|
|
465
|
+
...rest,
|
|
466
|
+
data: {
|
|
467
|
+
...(typeof summary === 'string' ? { summary } : {}),
|
|
468
|
+
...(typeof agentSummary === 'string' ? { agentSummary } : {}),
|
|
469
|
+
...(typeof description === 'string' ? { description } : {}),
|
|
470
|
+
...(presentation === true ? { presentation: true } : {}),
|
|
471
|
+
...(Array.isArray(slideTitles) ? { slideTitles } : {}),
|
|
472
|
+
...(Array.isArray(embeddedNodeIds) ? { embeddedNodeIds } : {}),
|
|
473
|
+
...(Array.isArray(embeddedUrls) ? { embeddedUrls } : {}),
|
|
474
|
+
},
|
|
475
|
+
});
|
|
446
476
|
}
|
|
447
477
|
|
|
448
478
|
async addHtmlPrimitive(input: AddHtmlPrimitiveInput): Promise<AddHtmlPrimitiveResult> {
|
package/src/mcp/server.ts
CHANGED
|
@@ -403,10 +403,17 @@ export async function startMcpServer(): Promise<void> {
|
|
|
403
403
|
// ── canvas_add_html_node ────────────────────────────────────────
|
|
404
404
|
server.tool(
|
|
405
405
|
'canvas_add_html_node',
|
|
406
|
-
'Add
|
|
406
|
+
'Add a normal html node: a self-contained HTML document (with optional inline <script> and CDN <script src="...">) rendered inside a sandboxed iframe (sandbox="allow-scripts"). This is the default HTML surface for reports, widgets, and bespoke visualizations. Presentation mode is opt-in: only pass presentation:true when the user explicitly asks for a deck/fullscreen presentation, or use canvas_add_html_primitive with kind="presentation". The iframe inherits live canvas theme tokens via injected CSS custom properties (both --c-* and common --color-* aliases) so authored HTML using var(--color-text-secondary), var(--color-bg), etc. renders cohesively. No same-origin access; no top-navigation; no forms. For declarative-only views with zero JS, prefer canvas_add_json_render_node. For React + shadcn + routing or multi-component apps, use canvas_build_web_artifact.',
|
|
407
407
|
{
|
|
408
408
|
html: z.string().describe('HTML document or fragment. Full <html>...</html> documents are passed through with theme styles injected into <head>; bare fragments are wrapped in a minimal document. Inline <script> and remote CDN <script src="..."> are allowed.'),
|
|
409
409
|
title: z.string().optional().describe('Node title shown in the canvas titlebar.'),
|
|
410
|
+
summary: z.string().optional().describe('Agent-readable semantic summary for this HTML node. If omitted, PMX derives one from visible HTML text.'),
|
|
411
|
+
agentSummary: z.string().optional().describe('Explicit agent-readable summary. Alias for summary with higher priority when both are provided.'),
|
|
412
|
+
description: z.string().optional().describe('Short description included in search and pinned/spatial context.'),
|
|
413
|
+
presentation: z.boolean().optional().describe('Marks this HTML surface as a fullscreen presentation/deck. Omit unless the user explicitly requested presentation mode.'),
|
|
414
|
+
slideTitles: z.array(z.string()).optional().describe('Agent-readable slide titles for presentation HTML.'),
|
|
415
|
+
embeddedNodeIds: z.array(z.string()).optional().describe('Canvas node IDs embedded or represented by this HTML surface.'),
|
|
416
|
+
embeddedUrls: z.array(z.string()).optional().describe('URLs embedded or represented by this HTML surface.'),
|
|
410
417
|
x: z.number().optional().describe('X position (auto-placed if omitted).'),
|
|
411
418
|
y: z.number().optional().describe('Y position (auto-placed if omitted).'),
|
|
412
419
|
width: z.number().optional().describe('Width in pixels (default: 720).'),
|
|
@@ -420,6 +427,13 @@ export async function startMcpServer(): Promise<void> {
|
|
|
420
427
|
const id = await c.addHtmlNode({
|
|
421
428
|
html: input.html,
|
|
422
429
|
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
|
430
|
+
...(typeof input.summary === 'string' ? { summary: input.summary } : {}),
|
|
431
|
+
...(typeof input.agentSummary === 'string' ? { agentSummary: input.agentSummary } : {}),
|
|
432
|
+
...(typeof input.description === 'string' ? { description: input.description } : {}),
|
|
433
|
+
...(input.presentation === true ? { presentation: true } : {}),
|
|
434
|
+
...(Array.isArray(input.slideTitles) ? { slideTitles: input.slideTitles } : {}),
|
|
435
|
+
...(Array.isArray(input.embeddedNodeIds) ? { embeddedNodeIds: input.embeddedNodeIds } : {}),
|
|
436
|
+
...(Array.isArray(input.embeddedUrls) ? { embeddedUrls: input.embeddedUrls } : {}),
|
|
423
437
|
...(typeof input.x === 'number' ? { x: input.x } : {}),
|
|
424
438
|
...(typeof input.y === 'number' ? { y: input.y } : {}),
|
|
425
439
|
...(typeof input.width === 'number' ? { width: input.width } : {}),
|
|
@@ -434,11 +448,11 @@ export async function startMcpServer(): Promise<void> {
|
|
|
434
448
|
|
|
435
449
|
server.tool(
|
|
436
450
|
'canvas_add_html_primitive',
|
|
437
|
-
'Create a reusable HTML communication primitive as a normal sandboxed html node. Use this instead of long markdown for side-by-side choices, implementation plans, PR review sheets, module maps, design sheets, component galleries, flowcharts,
|
|
451
|
+
'Create a reusable HTML communication primitive as a normal sandboxed html node. Use this instead of long markdown for side-by-side choices, implementation plans, PR review sheets, module maps, design sheets, component galleries, flowcharts, explainers, status reports, and throwaway editors with export/copy paths. Use kind="presentation" only when the user explicitly asks for a PowerPoint-like deck, pitch, briefing, workshop walkthrough, or fullscreen story.',
|
|
438
452
|
{
|
|
439
453
|
kind: htmlPrimitiveKindSchema.describe('Primitive kind. Call canvas_describe_schema and read htmlPrimitives for data shapes and examples.'),
|
|
440
454
|
title: z.string().optional().describe('Node title shown in the canvas titlebar.'),
|
|
441
|
-
data: z.record(z.string(), z.unknown()).optional().describe('Primitive-specific data payload. See canvas_describe_schema.htmlPrimitives for each shape.'),
|
|
455
|
+
data: z.record(z.string(), z.unknown()).optional().describe('Primitive-specific data payload. For kind="presentation", data may include theme:"canvas"|"midnight"|"paper"|"aurora" or a custom color object. See canvas_describe_schema.htmlPrimitives for each shape.'),
|
|
442
456
|
x: z.number().optional().describe('X position (auto-placed if omitted).'),
|
|
443
457
|
y: z.number().optional().describe('Y position (auto-placed if omitted).'),
|
|
444
458
|
width: z.number().optional().describe('Width in pixels (defaults per primitive).'),
|
|
@@ -192,6 +192,18 @@ function metadataForNode(node: CanvasNodeState): Record<string, unknown> | undef
|
|
|
192
192
|
}
|
|
193
193
|
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
|
194
194
|
}
|
|
195
|
+
case 'html': {
|
|
196
|
+
const metadata: Record<string, unknown> = {};
|
|
197
|
+
for (const key of ['summary', 'description', 'agentSummary', 'contentSummary', 'htmlPrimitive', 'presentation', 'slideCount', 'slideTitles', 'speakerNotes', 'embeddedNodeIds', 'embeddedUrls']) {
|
|
198
|
+
const value = node.data[key];
|
|
199
|
+
if (Array.isArray(value)) {
|
|
200
|
+
if (value.length > 0) metadata[key] = value;
|
|
201
|
+
} else if (value !== undefined && value !== null && value !== '') {
|
|
202
|
+
metadata[key] = value;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
|
206
|
+
}
|
|
195
207
|
case 'mcp-app': {
|
|
196
208
|
const metadata: Record<string, unknown> = {};
|
|
197
209
|
for (const key of ['url', 'path', 'mode', 'hostMode', 'viewerType', 'serverName', 'toolName', 'resourceUri', 'sessionStatus', 'projectPath', 'artifactBytes', 'sourceFiles', 'sourceFileCount', 'deps']) {
|
|
@@ -245,10 +257,20 @@ export function summarizeNodeForAgentContext(
|
|
|
245
257
|
return stringifyContextValue(node.data.spec ?? {}, defaultTextLength);
|
|
246
258
|
}
|
|
247
259
|
case 'html': {
|
|
260
|
+
if (typeof node.data.agentSummary === 'string') {
|
|
261
|
+
return truncateContextText(node.data.agentSummary, defaultTextLength);
|
|
262
|
+
}
|
|
248
263
|
if (typeof node.data.htmlPrimitive === 'string') {
|
|
249
264
|
return summarizeHtmlPrimitiveData(node.data, defaultTextLength);
|
|
250
265
|
}
|
|
251
|
-
return stringifyContextValue({
|
|
266
|
+
return stringifyContextValue({
|
|
267
|
+
title: node.data.title,
|
|
268
|
+
description: node.data.description,
|
|
269
|
+
summary: node.data.summary,
|
|
270
|
+
contentSummary: node.data.contentSummary,
|
|
271
|
+
embeddedNodeIds: node.data.embeddedNodeIds,
|
|
272
|
+
embeddedUrls: node.data.embeddedUrls,
|
|
273
|
+
}, defaultTextLength);
|
|
252
274
|
}
|
|
253
275
|
case 'prompt':
|
|
254
276
|
case 'response': {
|
|
@@ -1552,8 +1552,16 @@ export async function executeCanvasBatch(
|
|
|
1552
1552
|
};
|
|
1553
1553
|
} else {
|
|
1554
1554
|
const data = isPlainRecord(args.data) ? args.data : {};
|
|
1555
|
-
const htmlData = type === 'html'
|
|
1556
|
-
? {
|
|
1555
|
+
const htmlData = type === 'html'
|
|
1556
|
+
? {
|
|
1557
|
+
...data,
|
|
1558
|
+
...(typeof args.html === 'string' ? { html: args.html } : {}),
|
|
1559
|
+
...(typeof args.summary === 'string' ? { summary: args.summary } : {}),
|
|
1560
|
+
...(typeof args.agentSummary === 'string' ? { agentSummary: args.agentSummary } : {}),
|
|
1561
|
+
...(typeof args.description === 'string' ? { description: args.description } : {}),
|
|
1562
|
+
...(Array.isArray(args.embeddedNodeIds) ? { embeddedNodeIds: args.embeddedNodeIds } : {}),
|
|
1563
|
+
...(Array.isArray(args.embeddedUrls) ? { embeddedUrls: args.embeddedUrls } : {}),
|
|
1564
|
+
}
|
|
1557
1565
|
: data;
|
|
1558
1566
|
const created = addCanvasNode({
|
|
1559
1567
|
type: type as CanvasNodeState['type'],
|
|
@@ -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.' },
|
|
248
|
+
{ name: 'embeddedNodeIds', type: 'string[]', required: false, description: 'Canvas node IDs represented or iframe-embedded by this HTML surface.' },
|
|
249
|
+
{ name: 'embeddedUrls', type: 'string[]', required: false, description: 'URLs represented or iframe-embedded by this HTML surface.' },
|
|
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.' },
|
|
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
|
},
|
|
@@ -76,10 +76,14 @@ export function getCanvasNodeTitle(node: CanvasNodeState): string | null {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
export function getCanvasNodeContent(node: CanvasNodeState): string | null {
|
|
79
|
-
if (node.type === 'html'
|
|
80
|
-
const primitive = node.data.htmlPrimitive;
|
|
79
|
+
if (node.type === 'html') {
|
|
80
|
+
const primitive = typeof node.data.htmlPrimitive === 'string' ? node.data.htmlPrimitive : null;
|
|
81
81
|
const description = pickString(node.data.description);
|
|
82
|
-
return
|
|
82
|
+
return pickString(node.data.agentSummary)
|
|
83
|
+
?? pickString(node.data.contentSummary)
|
|
84
|
+
?? (primitive
|
|
85
|
+
? (description ? `${primitive}: ${description}` : primitive)
|
|
86
|
+
: null);
|
|
83
87
|
}
|
|
84
88
|
return pickString(node.data.content)
|
|
85
89
|
?? pickString(node.data.fileContent)
|
|
@@ -92,12 +96,13 @@ export function getCanvasNodeContent(node: CanvasNodeState): string | null {
|
|
|
92
96
|
|
|
93
97
|
export function serializeCanvasNode(node: CanvasNodeState): SerializedCanvasNode {
|
|
94
98
|
const data = normalizeCanvasNodeData(node.type, node.data);
|
|
99
|
+
const normalizedNode = { ...node, data };
|
|
95
100
|
return {
|
|
96
101
|
...node,
|
|
97
102
|
data,
|
|
98
103
|
kind: getCanvasNodeKind(node, data),
|
|
99
|
-
title: getCanvasNodeTitle(
|
|
100
|
-
content: getCanvasNodeContent(
|
|
104
|
+
title: getCanvasNodeTitle(normalizedNode),
|
|
105
|
+
content: getCanvasNodeContent(normalizedNode),
|
|
101
106
|
path: pickString(data.path),
|
|
102
107
|
url: pickString(data.url),
|
|
103
108
|
provenance: pickProvenance(data.provenance),
|