@mcp-html-bridge/ui-engine 0.1.0
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/package.json +26 -0
- package/src/bridge.ts +98 -0
- package/src/data-sniffer.ts +177 -0
- package/src/engine.ts +123 -0
- package/src/html-builder.ts +61 -0
- package/src/index.ts +20 -0
- package/src/playground.ts +187 -0
- package/src/renderers/composite.ts +73 -0
- package/src/renderers/data-grid.ts +159 -0
- package/src/renderers/form.ts +200 -0
- package/src/renderers/json-tree.ts +141 -0
- package/src/renderers/metrics-card.ts +108 -0
- package/src/renderers/reading-block.ts +114 -0
- package/src/theme.ts +184 -0
- package/src/types.ts +83 -0
- package/tsconfig.json +8 -0
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mcp-html-bridge/ui-engine",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Core UI rendering engine — schema/data → self-contained HTML",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc -p tsconfig.json",
|
|
15
|
+
"clean": "rm -rf dist"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/zhongkai/mcp-html-bridge.git",
|
|
24
|
+
"directory": "packages/core-ui-engine"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/bridge.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// ── Bridge Protocol: postMessage/CustomEvent for bidirectional communication ──
|
|
2
|
+
|
|
3
|
+
/** Generate inline JS for MCP bridge communication */
|
|
4
|
+
export function generateBridgeJS(): string {
|
|
5
|
+
return `
|
|
6
|
+
// ── MCP Bridge Protocol ──
|
|
7
|
+
(function() {
|
|
8
|
+
'use strict';
|
|
9
|
+
var MCP_BRIDGE = {
|
|
10
|
+
/** Dispatch a tool call event to the parent frame */
|
|
11
|
+
callTool: function(toolName, args) {
|
|
12
|
+
var payload = { type: 'MCP_TOOL_CALL', toolName: toolName, arguments: args, timestamp: Date.now() };
|
|
13
|
+
// postMessage to parent (for iframe embedding)
|
|
14
|
+
if (window.parent !== window) {
|
|
15
|
+
window.parent.postMessage(payload, '*');
|
|
16
|
+
}
|
|
17
|
+
// Also fire a CustomEvent for local listeners
|
|
18
|
+
window.dispatchEvent(new CustomEvent('mcp:tool-call', { detail: payload }));
|
|
19
|
+
__mcpLog('→ Tool call: ' + toolName, 'outbound');
|
|
20
|
+
return payload;
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
/** Register a result handler */
|
|
24
|
+
onResult: function(callback) {
|
|
25
|
+
window.addEventListener('message', function(evt) {
|
|
26
|
+
if (evt.data && evt.data.type === 'MCP_RESULT') {
|
|
27
|
+
callback(evt.data);
|
|
28
|
+
__mcpLog('← Result received', 'inbound');
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
window.addEventListener('mcp:result', function(evt) {
|
|
32
|
+
callback(evt.detail);
|
|
33
|
+
__mcpLog('← Result received (local)', 'inbound');
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
/** Notify parent of iframe height for auto-resize */
|
|
38
|
+
notifyHeight: function() {
|
|
39
|
+
if (window.parent !== window) {
|
|
40
|
+
var height = document.documentElement.scrollHeight;
|
|
41
|
+
window.parent.postMessage({ type: 'MCP_RESIZE', height: height }, '*');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Expose globally
|
|
47
|
+
window.__mcpBridge = MCP_BRIDGE;
|
|
48
|
+
|
|
49
|
+
// Auto-resize notification
|
|
50
|
+
var resizeObserver = new ResizeObserver(function() { MCP_BRIDGE.notifyHeight(); });
|
|
51
|
+
resizeObserver.observe(document.body);
|
|
52
|
+
|
|
53
|
+
// ── Form serialization helper ──
|
|
54
|
+
window.__mcpSubmit = function(event) {
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
var form = event.target;
|
|
57
|
+
var data = {};
|
|
58
|
+
var formData = new FormData(form);
|
|
59
|
+
formData.forEach(function(value, key) {
|
|
60
|
+
// Handle radio groups and checkboxes
|
|
61
|
+
var el = form.elements[key];
|
|
62
|
+
if (el && el.type === 'checkbox') {
|
|
63
|
+
data[key] = el.checked;
|
|
64
|
+
} else if (el && el.type === 'number') {
|
|
65
|
+
data[key] = value === '' ? null : Number(value);
|
|
66
|
+
} else {
|
|
67
|
+
// Try parsing as JSON for array/object fields
|
|
68
|
+
try {
|
|
69
|
+
var parsed = JSON.parse(value);
|
|
70
|
+
if (typeof parsed === 'object') {
|
|
71
|
+
data[key] = parsed;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
} catch(e) { /* not JSON, use as string */ }
|
|
75
|
+
data[key] = value;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
var toolName = form.dataset.toolName || document.title || 'unknown';
|
|
80
|
+
MCP_BRIDGE.callTool(toolName, data);
|
|
81
|
+
return false;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ── Console logging helper (used by playground) ──
|
|
85
|
+
var logContainer = null;
|
|
86
|
+
window.__mcpLog = function(msg, type) {
|
|
87
|
+
if (!logContainer) logContainer = document.getElementById('mcp-console');
|
|
88
|
+
if (!logContainer) return;
|
|
89
|
+
var entry = document.createElement('div');
|
|
90
|
+
entry.className = 'console-entry console-' + (type || 'info');
|
|
91
|
+
var ts = new Date().toLocaleTimeString();
|
|
92
|
+
entry.textContent = '[' + ts + '] ' + msg;
|
|
93
|
+
logContainer.appendChild(entry);
|
|
94
|
+
logContainer.scrollTop = logContainer.scrollHeight;
|
|
95
|
+
};
|
|
96
|
+
})();
|
|
97
|
+
`;
|
|
98
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// ── Data Sniffer: confidence-scored heuristic engine ──
|
|
2
|
+
import type { SniffResult, RenderIntent } from './types.js';
|
|
3
|
+
|
|
4
|
+
/** Measure maximum nesting depth of a value */
|
|
5
|
+
function measureDepth(val: unknown, current = 0): number {
|
|
6
|
+
if (current > 20) return current; // guard
|
|
7
|
+
if (Array.isArray(val)) {
|
|
8
|
+
return val.reduce<number>((max, item) => Math.max(max, measureDepth(item, current + 1)), current);
|
|
9
|
+
}
|
|
10
|
+
if (val !== null && typeof val === 'object') {
|
|
11
|
+
return Object.values(val as Record<string, unknown>).reduce<number>(
|
|
12
|
+
(max, v) => Math.max(max, measureDepth(v, current + 1)),
|
|
13
|
+
current
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
return current;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Check if an array of objects has consistent keys */
|
|
20
|
+
function keyConsistency(arr: Record<string, unknown>[]): number {
|
|
21
|
+
if (arr.length === 0) return 0;
|
|
22
|
+
const firstKeys = new Set(Object.keys(arr[0]));
|
|
23
|
+
let matchCount = 0;
|
|
24
|
+
for (let i = 1; i < arr.length; i++) {
|
|
25
|
+
const keys = Object.keys(arr[i]);
|
|
26
|
+
const overlap = keys.filter((k) => firstKeys.has(k)).length;
|
|
27
|
+
matchCount += overlap / Math.max(firstKeys.size, keys.length);
|
|
28
|
+
}
|
|
29
|
+
return matchCount / (arr.length - 1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const READING_KEYS = new Set([
|
|
33
|
+
'description', 'content', 'body', 'text', 'message',
|
|
34
|
+
'summary', 'readme', 'notes', 'details', 'markdown',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** Detect data-grid intent */
|
|
38
|
+
function detectDataGrid(data: unknown): SniffResult | null {
|
|
39
|
+
if (!Array.isArray(data) || data.length === 0) return null;
|
|
40
|
+
const objects = data.filter(
|
|
41
|
+
(item): item is Record<string, unknown> =>
|
|
42
|
+
item !== null && typeof item === 'object' && !Array.isArray(item)
|
|
43
|
+
);
|
|
44
|
+
if (objects.length < 2) return null;
|
|
45
|
+
|
|
46
|
+
const consistency = keyConsistency(objects);
|
|
47
|
+
if (consistency < 0.5) return null;
|
|
48
|
+
|
|
49
|
+
const confidence = Math.min(0.95, 0.5 + consistency * 0.3 + Math.min(objects.length / 20, 0.15));
|
|
50
|
+
return {
|
|
51
|
+
intent: 'data-grid',
|
|
52
|
+
confidence,
|
|
53
|
+
metadata: {
|
|
54
|
+
rowCount: objects.length,
|
|
55
|
+
columns: Object.keys(objects[0]),
|
|
56
|
+
keyConsistency: consistency,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Detect metrics-card intent */
|
|
62
|
+
function detectMetricsCard(data: unknown): SniffResult | null {
|
|
63
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) return null;
|
|
64
|
+
const entries = Object.entries(data as Record<string, unknown>);
|
|
65
|
+
if (entries.length === 0 || entries.length > 12) return null;
|
|
66
|
+
|
|
67
|
+
const numericCount = entries.filter(([, v]) => typeof v === 'number').length;
|
|
68
|
+
const ratio = numericCount / entries.length;
|
|
69
|
+
|
|
70
|
+
if (numericCount < 1 || ratio < 0.3) return null;
|
|
71
|
+
|
|
72
|
+
const confidence = Math.min(0.9, 0.4 + ratio * 0.4 + (entries.length <= 6 ? 0.1 : 0));
|
|
73
|
+
return {
|
|
74
|
+
intent: 'metrics-card',
|
|
75
|
+
confidence,
|
|
76
|
+
metadata: {
|
|
77
|
+
numericKeys: entries.filter(([, v]) => typeof v === 'number').map(([k]) => k),
|
|
78
|
+
totalKeys: entries.length,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Detect json-tree intent */
|
|
84
|
+
function detectJsonTree(data: unknown): SniffResult | null {
|
|
85
|
+
const depth = measureDepth(data);
|
|
86
|
+
if (depth < 3) return null;
|
|
87
|
+
|
|
88
|
+
const confidence = Math.min(0.85, 0.3 + (depth - 2) * 0.1);
|
|
89
|
+
return {
|
|
90
|
+
intent: 'json-tree',
|
|
91
|
+
confidence,
|
|
92
|
+
metadata: { depth },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Detect reading-block intent */
|
|
97
|
+
function detectReadingBlock(data: unknown): SniffResult | null {
|
|
98
|
+
if (typeof data === 'string' && data.length > 200) {
|
|
99
|
+
return {
|
|
100
|
+
intent: 'reading-block',
|
|
101
|
+
confidence: 0.85,
|
|
102
|
+
metadata: { length: data.length },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (data !== null && typeof data === 'object' && !Array.isArray(data)) {
|
|
106
|
+
const entries = Object.entries(data as Record<string, unknown>);
|
|
107
|
+
const longTextEntries = entries.filter(
|
|
108
|
+
([k, v]) =>
|
|
109
|
+
(typeof v === 'string' && v.length > 200) || READING_KEYS.has(k.toLowerCase())
|
|
110
|
+
);
|
|
111
|
+
if (longTextEntries.length > 0) {
|
|
112
|
+
const confidence = Math.min(0.8, 0.4 + longTextEntries.length * 0.15);
|
|
113
|
+
return {
|
|
114
|
+
intent: 'reading-block',
|
|
115
|
+
confidence,
|
|
116
|
+
metadata: { textKeys: longTextEntries.map(([k]) => k) },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Main sniff function: returns ranked intents */
|
|
124
|
+
export function sniff(data: unknown): SniffResult[] {
|
|
125
|
+
const results: SniffResult[] = [];
|
|
126
|
+
|
|
127
|
+
const grid = detectDataGrid(data);
|
|
128
|
+
if (grid) results.push(grid);
|
|
129
|
+
|
|
130
|
+
const metrics = detectMetricsCard(data);
|
|
131
|
+
if (metrics) results.push(metrics);
|
|
132
|
+
|
|
133
|
+
const tree = detectJsonTree(data);
|
|
134
|
+
if (tree) results.push(tree);
|
|
135
|
+
|
|
136
|
+
const reading = detectReadingBlock(data);
|
|
137
|
+
if (reading) results.push(reading);
|
|
138
|
+
|
|
139
|
+
// If multiple intents detected with similar confidence, suggest composite
|
|
140
|
+
if (results.length > 1) {
|
|
141
|
+
const topConfidence = Math.max(...results.map((r) => r.confidence));
|
|
142
|
+
const close = results.filter((r) => topConfidence - r.confidence < 0.2);
|
|
143
|
+
if (close.length > 1) {
|
|
144
|
+
results.push({
|
|
145
|
+
intent: 'composite',
|
|
146
|
+
confidence: topConfidence * 0.9,
|
|
147
|
+
metadata: { subIntents: close.map((r) => r.intent) },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Fallback: if nothing detected, default to json-tree
|
|
153
|
+
if (results.length === 0) {
|
|
154
|
+
results.push({
|
|
155
|
+
intent: 'json-tree',
|
|
156
|
+
confidence: 0.3,
|
|
157
|
+
metadata: { fallback: true },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return results.sort((a, b) => b.confidence - a.confidence);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Detect intent from JSON Schema (for form rendering) */
|
|
165
|
+
export function sniffSchema(schema: Record<string, unknown>): SniffResult {
|
|
166
|
+
const props = schema['properties'] as Record<string, unknown> | undefined;
|
|
167
|
+
const propCount = props ? Object.keys(props).length : 0;
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
intent: 'form',
|
|
171
|
+
confidence: 0.95,
|
|
172
|
+
metadata: {
|
|
173
|
+
propertyCount: propCount,
|
|
174
|
+
required: schema['required'] ?? [],
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// ── Engine Orchestrator: main entry point ──
|
|
2
|
+
import type { EngineInput, RenderOptions, JSONSchema } from './types.js';
|
|
3
|
+
import { document as htmlDocument } from './html-builder.js';
|
|
4
|
+
import { generateThemeCSS } from './theme.js';
|
|
5
|
+
import { generateBridgeJS } from './bridge.js';
|
|
6
|
+
import { sniff, sniffSchema } from './data-sniffer.js';
|
|
7
|
+
import { renderForm, getFormCSS } from './renderers/form.js';
|
|
8
|
+
import { renderDataGrid, getDataGridCSS, getDataGridJS } from './renderers/data-grid.js';
|
|
9
|
+
import { renderJsonTree, getJsonTreeCSS, getJsonTreeJS } from './renderers/json-tree.js';
|
|
10
|
+
import { renderReadingBlock, getReadingBlockCSS } from './renderers/reading-block.js';
|
|
11
|
+
import { renderMetricsCard, getMetricsCardCSS } from './renderers/metrics-card.js';
|
|
12
|
+
import { renderComposite, getCompositeCSS } from './renderers/composite.js';
|
|
13
|
+
import { generatePlaygroundHTML, getPlaygroundCSS, getPlaygroundJS } from './playground.js';
|
|
14
|
+
|
|
15
|
+
/** Render a form from a JSON Schema (for tool input) */
|
|
16
|
+
export function renderFromSchema(
|
|
17
|
+
schema: JSONSchema,
|
|
18
|
+
options: RenderOptions & { toolName?: string; toolDescription?: string } = {}
|
|
19
|
+
): string {
|
|
20
|
+
const sniffResult = sniffSchema(schema as Record<string, unknown>);
|
|
21
|
+
|
|
22
|
+
const body = renderForm(schema, {
|
|
23
|
+
...sniffResult.metadata,
|
|
24
|
+
toolName: options.toolName,
|
|
25
|
+
toolDescription: options.toolDescription,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const playground = options.debug ? generatePlaygroundHTML() : '';
|
|
29
|
+
|
|
30
|
+
const css = [
|
|
31
|
+
generateThemeCSS(),
|
|
32
|
+
getFormCSS(),
|
|
33
|
+
options.debug ? getPlaygroundCSS() : '',
|
|
34
|
+
].join('\n');
|
|
35
|
+
|
|
36
|
+
const js = [
|
|
37
|
+
generateBridgeJS(),
|
|
38
|
+
options.debug ? getPlaygroundJS() : '',
|
|
39
|
+
].join('\n');
|
|
40
|
+
|
|
41
|
+
return htmlDocument({
|
|
42
|
+
title: options.title ?? options.toolName ?? 'MCP Tool',
|
|
43
|
+
css,
|
|
44
|
+
body: body + playground,
|
|
45
|
+
js,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Render HTML from tool result data */
|
|
50
|
+
export function renderFromData(
|
|
51
|
+
data: unknown,
|
|
52
|
+
options: RenderOptions & { toolName?: string; toolDescription?: string } = {}
|
|
53
|
+
): string {
|
|
54
|
+
const results = sniff(data);
|
|
55
|
+
const best = results[0];
|
|
56
|
+
|
|
57
|
+
let body: string;
|
|
58
|
+
const cssParts = [generateThemeCSS()];
|
|
59
|
+
const jsParts = [generateBridgeJS()];
|
|
60
|
+
|
|
61
|
+
switch (best.intent) {
|
|
62
|
+
case 'data-grid':
|
|
63
|
+
body = renderDataGrid(data, best.metadata);
|
|
64
|
+
cssParts.push(getDataGridCSS());
|
|
65
|
+
jsParts.push(getDataGridJS());
|
|
66
|
+
break;
|
|
67
|
+
case 'metrics-card':
|
|
68
|
+
body = renderMetricsCard(data, best.metadata);
|
|
69
|
+
cssParts.push(getMetricsCardCSS());
|
|
70
|
+
break;
|
|
71
|
+
case 'reading-block':
|
|
72
|
+
body = renderReadingBlock(data, best.metadata);
|
|
73
|
+
cssParts.push(getReadingBlockCSS());
|
|
74
|
+
break;
|
|
75
|
+
case 'json-tree':
|
|
76
|
+
body = renderJsonTree(data, best.metadata);
|
|
77
|
+
cssParts.push(getJsonTreeCSS());
|
|
78
|
+
jsParts.push(getJsonTreeJS());
|
|
79
|
+
// Inject tree data for copy-all
|
|
80
|
+
jsParts.push(`__mcpTreeData = ${JSON.stringify(data)};`);
|
|
81
|
+
break;
|
|
82
|
+
case 'composite':
|
|
83
|
+
body = renderComposite(data, best.metadata);
|
|
84
|
+
// Composite may use all renderers, include all CSS/JS
|
|
85
|
+
cssParts.push(getDataGridCSS(), getMetricsCardCSS(), getReadingBlockCSS(), getJsonTreeCSS(), getCompositeCSS());
|
|
86
|
+
jsParts.push(getDataGridJS(), getJsonTreeJS());
|
|
87
|
+
break;
|
|
88
|
+
default:
|
|
89
|
+
body = renderJsonTree(data, best.metadata);
|
|
90
|
+
cssParts.push(getJsonTreeCSS());
|
|
91
|
+
jsParts.push(getJsonTreeJS());
|
|
92
|
+
jsParts.push(`__mcpTreeData = ${JSON.stringify(data)};`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (options.debug) {
|
|
96
|
+
body += generatePlaygroundHTML();
|
|
97
|
+
cssParts.push(getPlaygroundCSS());
|
|
98
|
+
jsParts.push(getPlaygroundJS());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return htmlDocument({
|
|
102
|
+
title: options.title ?? options.toolName ?? 'MCP Result',
|
|
103
|
+
css: cssParts.join('\n'),
|
|
104
|
+
body,
|
|
105
|
+
js: jsParts.join('\n'),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Unified API */
|
|
110
|
+
export function render(input: EngineInput, options: RenderOptions = {}): string {
|
|
111
|
+
if (input.mode === 'schema') {
|
|
112
|
+
return renderFromSchema(input.schema, {
|
|
113
|
+
...options,
|
|
114
|
+
toolName: input.toolName,
|
|
115
|
+
toolDescription: input.toolDescription,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return renderFromData(input.data, {
|
|
119
|
+
...options,
|
|
120
|
+
toolName: input.toolName,
|
|
121
|
+
toolDescription: input.toolDescription,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// ── Safe HTML string builder with XSS prevention ──
|
|
2
|
+
|
|
3
|
+
const ESCAPE_MAP: Record<string, string> = {
|
|
4
|
+
'&': '&',
|
|
5
|
+
'<': '<',
|
|
6
|
+
'>': '>',
|
|
7
|
+
'"': '"',
|
|
8
|
+
"'": ''',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Escape HTML special characters to prevent XSS */
|
|
12
|
+
export function escapeHtml(str: string): string {
|
|
13
|
+
return str.replace(/[&<>"']/g, (ch) => ESCAPE_MAP[ch] ?? ch);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Build an HTML opening tag with attributes */
|
|
17
|
+
export function tag(
|
|
18
|
+
name: string,
|
|
19
|
+
attrs: Record<string, string | boolean | undefined> = {},
|
|
20
|
+
selfClosing = false
|
|
21
|
+
): string {
|
|
22
|
+
const attrStr = Object.entries(attrs)
|
|
23
|
+
.filter(([, v]) => v !== undefined && v !== false)
|
|
24
|
+
.map(([k, v]) => (v === true ? k : `${k}="${escapeHtml(String(v))}"`))
|
|
25
|
+
.join(' ');
|
|
26
|
+
const open = attrStr ? `<${name} ${attrStr}` : `<${name}`;
|
|
27
|
+
return selfClosing ? `${open} />` : `${open}>`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Wrap content in a style tag */
|
|
31
|
+
export function style(css: string): string {
|
|
32
|
+
return `<style>\n${css}\n</style>`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Wrap content in a script tag */
|
|
36
|
+
export function script(js: string): string {
|
|
37
|
+
return `<script>\n${js}\n</script>`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Wrap content in a complete HTML document */
|
|
41
|
+
export function document(opts: {
|
|
42
|
+
title?: string;
|
|
43
|
+
css?: string;
|
|
44
|
+
js?: string;
|
|
45
|
+
body: string;
|
|
46
|
+
}): string {
|
|
47
|
+
const titleTag = opts.title ? `<title>${escapeHtml(opts.title)}</title>` : '';
|
|
48
|
+
return `<!DOCTYPE html>
|
|
49
|
+
<html lang="en">
|
|
50
|
+
<head>
|
|
51
|
+
<meta charset="UTF-8">
|
|
52
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
53
|
+
${titleTag}
|
|
54
|
+
${opts.css ? style(opts.css) : ''}
|
|
55
|
+
</head>
|
|
56
|
+
<body>
|
|
57
|
+
${opts.body}
|
|
58
|
+
${opts.js ? script(opts.js) : ''}
|
|
59
|
+
</body>
|
|
60
|
+
</html>`;
|
|
61
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// ── Public API ──
|
|
2
|
+
export { render, renderFromSchema, renderFromData } from './engine.js';
|
|
3
|
+
export { sniff, sniffSchema } from './data-sniffer.js';
|
|
4
|
+
export { generateThemeCSS } from './theme.js';
|
|
5
|
+
export { generateBridgeJS } from './bridge.js';
|
|
6
|
+
export { escapeHtml, tag, style, script, document } from './html-builder.js';
|
|
7
|
+
|
|
8
|
+
// Re-export types
|
|
9
|
+
export type {
|
|
10
|
+
RenderIntent,
|
|
11
|
+
SniffResult,
|
|
12
|
+
RenderOptions,
|
|
13
|
+
EngineInput,
|
|
14
|
+
SchemaInput,
|
|
15
|
+
DataInput,
|
|
16
|
+
JSONSchema,
|
|
17
|
+
Renderer,
|
|
18
|
+
MCPToolDefinition,
|
|
19
|
+
MCPServerInfo,
|
|
20
|
+
} from './types.js';
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// ── Debug Playground: floating debug panel ──
|
|
2
|
+
import { escapeHtml } from './html-builder.js';
|
|
3
|
+
|
|
4
|
+
export function generatePlaygroundHTML(): string {
|
|
5
|
+
return `
|
|
6
|
+
<!-- Debug Playground -->
|
|
7
|
+
<button id="pg-toggle" class="pg-toggle" onclick="__mcpTogglePg()"></> Debug Playground</button>
|
|
8
|
+
<div id="pg-panel" class="pg-panel hidden">
|
|
9
|
+
<div class="pg-header">
|
|
10
|
+
<h3>Debug Playground</h3>
|
|
11
|
+
<button class="pg-close" onclick="__mcpTogglePg()">×</button>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="pg-body">
|
|
14
|
+
<!-- Config Section -->
|
|
15
|
+
<details class="pg-section" open>
|
|
16
|
+
<summary>Configuration</summary>
|
|
17
|
+
<div class="pg-config">
|
|
18
|
+
<label class="pg-label">API Base URL
|
|
19
|
+
<input type="text" id="pg-api-url" class="input" value="" placeholder="https://api.openai.com/v1">
|
|
20
|
+
</label>
|
|
21
|
+
<label class="pg-label">API Key
|
|
22
|
+
<input type="password" id="pg-api-key" class="input" value="" placeholder="sk-...">
|
|
23
|
+
</label>
|
|
24
|
+
<label class="pg-label">Model
|
|
25
|
+
<input type="text" id="pg-model" class="input" value="" placeholder="gpt-4">
|
|
26
|
+
</label>
|
|
27
|
+
<label class="pg-label">System Prompt
|
|
28
|
+
<textarea id="pg-system" class="input textarea" rows="3" placeholder="You are a helpful assistant..."></textarea>
|
|
29
|
+
</label>
|
|
30
|
+
<button class="btn btn-primary btn-sm" onclick="__mcpSaveConfig()">Save to LocalStorage</button>
|
|
31
|
+
</div>
|
|
32
|
+
</details>
|
|
33
|
+
|
|
34
|
+
<!-- Console Section -->
|
|
35
|
+
<details class="pg-section" open>
|
|
36
|
+
<summary>Console</summary>
|
|
37
|
+
<div id="mcp-console" class="pg-console"></div>
|
|
38
|
+
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('mcp-console').innerHTML=''">Clear</button>
|
|
39
|
+
</details>
|
|
40
|
+
|
|
41
|
+
<!-- Raw JSON Section -->
|
|
42
|
+
<details class="pg-section">
|
|
43
|
+
<summary>Raw JSON Injection</summary>
|
|
44
|
+
<textarea id="pg-json" class="input textarea pg-json" rows="8" placeholder="Paste JSON data here..."></textarea>
|
|
45
|
+
<button class="btn btn-primary btn-sm" onclick="__mcpInjectJson()">Inject & Re-render</button>
|
|
46
|
+
</details>
|
|
47
|
+
</div>
|
|
48
|
+
</div>`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getPlaygroundCSS(): string {
|
|
52
|
+
return `
|
|
53
|
+
.pg-toggle {
|
|
54
|
+
position: fixed; bottom: 16px; right: 16px; z-index: 9998;
|
|
55
|
+
padding: 8px 16px; border-radius: var(--radius-full);
|
|
56
|
+
background: var(--accent); color: var(--accent-text);
|
|
57
|
+
border: none; font-weight: 600; font-size: var(--text-sm);
|
|
58
|
+
cursor: pointer; box-shadow: var(--shadow-lg);
|
|
59
|
+
font-family: var(--font-mono);
|
|
60
|
+
transition: all var(--duration-fast) var(--ease-out);
|
|
61
|
+
}
|
|
62
|
+
.pg-toggle:hover { transform: scale(1.05); }
|
|
63
|
+
|
|
64
|
+
.pg-panel {
|
|
65
|
+
position: fixed; top: 0; right: 0; bottom: 0;
|
|
66
|
+
width: min(480px, 100vw); z-index: 9999;
|
|
67
|
+
background: var(--bg-primary); border-left: 1px solid var(--border);
|
|
68
|
+
box-shadow: var(--shadow-lg); display: flex; flex-direction: column;
|
|
69
|
+
transition: transform var(--duration-slow) var(--ease-out);
|
|
70
|
+
}
|
|
71
|
+
.pg-panel.hidden { transform: translateX(100%); pointer-events: none; }
|
|
72
|
+
|
|
73
|
+
.pg-header {
|
|
74
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
75
|
+
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border);
|
|
76
|
+
}
|
|
77
|
+
.pg-header h3 { font-size: var(--text-lg); font-weight: 700; }
|
|
78
|
+
.pg-close {
|
|
79
|
+
background: none; border: none; font-size: 24px; cursor: pointer;
|
|
80
|
+
color: var(--text-secondary); padding: 0 4px;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.pg-body { flex: 1; overflow-y: auto; padding: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-4); }
|
|
84
|
+
|
|
85
|
+
.pg-section { border: 1px solid var(--border); border-radius: var(--radius-sm); }
|
|
86
|
+
.pg-section summary {
|
|
87
|
+
padding: var(--sp-2) var(--sp-3); font-weight: 600; font-size: var(--text-sm);
|
|
88
|
+
cursor: pointer; user-select: none;
|
|
89
|
+
}
|
|
90
|
+
.pg-section > *:not(summary) { padding: var(--sp-3); }
|
|
91
|
+
|
|
92
|
+
.pg-config { display: flex; flex-direction: column; gap: var(--sp-3); }
|
|
93
|
+
.pg-label { display: flex; flex-direction: column; gap: var(--sp-1); font-size: var(--text-sm); font-weight: 500; }
|
|
94
|
+
|
|
95
|
+
.pg-console {
|
|
96
|
+
background: var(--bg-tertiary); border-radius: var(--radius-sm);
|
|
97
|
+
font-family: var(--font-mono); font-size: var(--text-xs);
|
|
98
|
+
min-height: 120px; max-height: 300px; overflow-y: auto; padding: var(--sp-2);
|
|
99
|
+
}
|
|
100
|
+
.console-entry { padding: 2px 0; border-bottom: 1px solid var(--border); }
|
|
101
|
+
.console-outbound { color: var(--accent); }
|
|
102
|
+
.console-inbound { color: var(--success); }
|
|
103
|
+
.console-error { color: var(--danger); }
|
|
104
|
+
.console-info { color: var(--text-secondary); }
|
|
105
|
+
|
|
106
|
+
.pg-json { font-family: var(--font-mono); font-size: var(--text-xs); }
|
|
107
|
+
`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function getPlaygroundJS(): string {
|
|
111
|
+
return `
|
|
112
|
+
// ── Playground Logic ──
|
|
113
|
+
(function() {
|
|
114
|
+
// Load saved config from localStorage
|
|
115
|
+
var fields = ['pg-api-url', 'pg-api-key', 'pg-model', 'pg-system'];
|
|
116
|
+
fields.forEach(function(id) {
|
|
117
|
+
var el = document.getElementById(id);
|
|
118
|
+
var saved = localStorage.getItem('mcp_' + id);
|
|
119
|
+
if (el && saved) el.value = saved;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
window.__mcpTogglePg = function() {
|
|
123
|
+
var panel = document.getElementById('pg-panel');
|
|
124
|
+
if (panel) panel.classList.toggle('hidden');
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
window.__mcpSaveConfig = function() {
|
|
128
|
+
fields.forEach(function(id) {
|
|
129
|
+
var el = document.getElementById(id);
|
|
130
|
+
if (el) localStorage.setItem('mcp_' + id, el.value);
|
|
131
|
+
});
|
|
132
|
+
__mcpLog('Config saved to localStorage', 'info');
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
window.__mcpInjectJson = function() {
|
|
136
|
+
var textarea = document.getElementById('pg-json');
|
|
137
|
+
if (!textarea) return;
|
|
138
|
+
try {
|
|
139
|
+
var data = JSON.parse(textarea.value);
|
|
140
|
+
__mcpLog('JSON parsed, dispatching re-render...', 'info');
|
|
141
|
+
window.dispatchEvent(new CustomEvent('mcp:inject', { detail: data }));
|
|
142
|
+
} catch(e) {
|
|
143
|
+
__mcpLog('JSON parse error: ' + e.message, 'error');
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Intercept form submissions for LLM relay
|
|
148
|
+
window.addEventListener('mcp:tool-call', function(evt) {
|
|
149
|
+
var apiUrl = (document.getElementById('pg-api-url') || {}).value;
|
|
150
|
+
var apiKey = (document.getElementById('pg-api-key') || {}).value;
|
|
151
|
+
var model = (document.getElementById('pg-model') || {}).value;
|
|
152
|
+
var system = (document.getElementById('pg-system') || {}).value;
|
|
153
|
+
|
|
154
|
+
if (!apiUrl || !apiKey) {
|
|
155
|
+
__mcpLog('No API config set — tool call logged but not forwarded', 'info');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
var detail = evt.detail;
|
|
160
|
+
__mcpLog('Forwarding to LLM: ' + model, 'outbound');
|
|
161
|
+
|
|
162
|
+
var body = {
|
|
163
|
+
model: model || 'gpt-4',
|
|
164
|
+
messages: [
|
|
165
|
+
{ role: 'system', content: system || 'You are a helpful assistant.' },
|
|
166
|
+
{ role: 'user', content: JSON.stringify(detail.arguments) }
|
|
167
|
+
]
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
fetch(apiUrl + '/chat/completions', {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey },
|
|
173
|
+
body: JSON.stringify(body)
|
|
174
|
+
})
|
|
175
|
+
.then(function(r) { return r.json(); })
|
|
176
|
+
.then(function(data) {
|
|
177
|
+
__mcpLog('LLM response received', 'inbound');
|
|
178
|
+
__mcpLog(JSON.stringify(data.choices?.[0]?.message?.content || data).substring(0, 200), 'info');
|
|
179
|
+
window.dispatchEvent(new CustomEvent('mcp:result', { detail: { type: 'MCP_RESULT', data: data } }));
|
|
180
|
+
})
|
|
181
|
+
.catch(function(err) {
|
|
182
|
+
__mcpLog('LLM error: ' + err.message, 'error');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
})();
|
|
186
|
+
`;
|
|
187
|
+
}
|