@mcp-html-bridge/ui-engine 0.3.0 → 0.5.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 +1 -1
- package/src/engine.ts +50 -104
- package/src/index.ts +9 -13
- package/src/llm-renderer.ts +129 -0
- package/src/renderer.ts +380 -0
- package/src/types.ts +0 -23
- package/src/utilities.ts +319 -0
- package/src/data-sniffer.ts +0 -177
- package/src/renderers/composite.ts +0 -73
- package/src/renderers/data-grid.ts +0 -159
- package/src/renderers/json-tree.ts +0 -141
- package/src/renderers/metrics-card.ts +0 -108
- package/src/renderers/reading-block.ts +0 -114
package/package.json
CHANGED
package/src/engine.ts
CHANGED
|
@@ -1,91 +1,80 @@
|
|
|
1
|
-
// ── Engine Orchestrator:
|
|
2
|
-
import type { EngineInput,
|
|
1
|
+
// ── Engine Orchestrator: JSON → self-contained HTML document ──
|
|
2
|
+
import type { EngineInput, RenderOptions, JSONSchema } from './types.js';
|
|
3
|
+
import type { LLMConfig } from './llm-renderer.js';
|
|
3
4
|
import { document as htmlDocument } from './html-builder.js';
|
|
4
5
|
import { generateThemeCSS } from './theme.js';
|
|
5
6
|
import { generateBridgeJS } from './bridge.js';
|
|
6
|
-
import {
|
|
7
|
+
import { renderJSON, getRendererCSS, getRendererJS } from './renderer.js';
|
|
8
|
+
import { renderWithLLM } from './llm-renderer.js';
|
|
7
9
|
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
10
|
import { generatePlaygroundHTML, getPlaygroundCSS, getPlaygroundJS } from './playground.js';
|
|
11
|
+
import { generateUtilityCSS } from './utilities.js';
|
|
14
12
|
|
|
15
|
-
/**
|
|
13
|
+
/** Options for rendering */
|
|
16
14
|
interface DataRenderOptions extends RenderOptions {
|
|
17
15
|
toolName?: string;
|
|
18
16
|
toolDescription?: string;
|
|
19
|
-
/**
|
|
20
|
-
|
|
17
|
+
/** LLM config for semantic rendering. Omit for structural fallback. */
|
|
18
|
+
llm?: LLMConfig;
|
|
21
19
|
}
|
|
22
20
|
|
|
23
|
-
/**
|
|
24
|
-
function
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
metadata: Record<string, unknown>,
|
|
28
|
-
options: DataRenderOptions
|
|
29
|
-
): string {
|
|
30
|
-
let body: string;
|
|
31
|
-
const cssParts = [generateThemeCSS()];
|
|
32
|
-
const jsParts = [generateBridgeJS()];
|
|
33
|
-
|
|
34
|
-
switch (intent) {
|
|
35
|
-
case 'data-grid':
|
|
36
|
-
body = renderDataGrid(data, metadata);
|
|
37
|
-
cssParts.push(getDataGridCSS());
|
|
38
|
-
jsParts.push(getDataGridJS());
|
|
39
|
-
break;
|
|
40
|
-
case 'metrics-card':
|
|
41
|
-
body = renderMetricsCard(data, metadata);
|
|
42
|
-
cssParts.push(getMetricsCardCSS());
|
|
43
|
-
break;
|
|
44
|
-
case 'reading-block':
|
|
45
|
-
body = renderReadingBlock(data, metadata);
|
|
46
|
-
cssParts.push(getReadingBlockCSS());
|
|
47
|
-
break;
|
|
48
|
-
case 'json-tree':
|
|
49
|
-
body = renderJsonTree(data, metadata);
|
|
50
|
-
cssParts.push(getJsonTreeCSS());
|
|
51
|
-
jsParts.push(getJsonTreeJS());
|
|
52
|
-
jsParts.push(`__mcpTreeData = ${JSON.stringify(data)};`);
|
|
53
|
-
break;
|
|
54
|
-
case 'composite':
|
|
55
|
-
body = renderComposite(data, metadata);
|
|
56
|
-
cssParts.push(getDataGridCSS(), getMetricsCardCSS(), getReadingBlockCSS(), getJsonTreeCSS(), getCompositeCSS());
|
|
57
|
-
jsParts.push(getDataGridJS(), getJsonTreeJS());
|
|
58
|
-
break;
|
|
59
|
-
default:
|
|
60
|
-
body = renderJsonTree(data, metadata);
|
|
61
|
-
cssParts.push(getJsonTreeCSS());
|
|
62
|
-
jsParts.push(getJsonTreeJS());
|
|
63
|
-
jsParts.push(`__mcpTreeData = ${JSON.stringify(data)};`);
|
|
64
|
-
}
|
|
21
|
+
/** Build the document wrapper around an HTML body fragment */
|
|
22
|
+
function buildDocument(body: string, options: DataRenderOptions, extraCSS = '', extraJS = ''): string {
|
|
23
|
+
const cssParts = [generateThemeCSS(), generateUtilityCSS(), extraCSS];
|
|
24
|
+
const jsParts = [generateBridgeJS(), extraJS];
|
|
65
25
|
|
|
66
26
|
if (options.debug) {
|
|
67
|
-
body += generatePlaygroundHTML();
|
|
68
27
|
cssParts.push(getPlaygroundCSS());
|
|
69
28
|
jsParts.push(getPlaygroundJS());
|
|
70
29
|
}
|
|
71
30
|
|
|
31
|
+
const playgroundHTML = options.debug ? generatePlaygroundHTML() : '';
|
|
32
|
+
|
|
72
33
|
return htmlDocument({
|
|
73
34
|
title: options.title ?? options.toolName ?? 'MCP Result',
|
|
74
35
|
css: cssParts.join('\n'),
|
|
75
|
-
body,
|
|
36
|
+
body: body + playgroundHTML,
|
|
76
37
|
js: jsParts.join('\n'),
|
|
77
38
|
});
|
|
78
39
|
}
|
|
79
40
|
|
|
80
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Render any JSON data as a full HTML document.
|
|
43
|
+
*
|
|
44
|
+
* - If `options.llm` is provided: calls the LLM for semantic rendering (async).
|
|
45
|
+
* - If omitted: uses the structural renderer (sync, returned as resolved Promise).
|
|
46
|
+
*/
|
|
47
|
+
export async function renderFromData(
|
|
48
|
+
data: unknown,
|
|
49
|
+
options: DataRenderOptions = {}
|
|
50
|
+
): Promise<string> {
|
|
51
|
+
if (options.llm) {
|
|
52
|
+
const body = await renderWithLLM(data, options.llm);
|
|
53
|
+
// Always include structural CSS/JS — the LLM fallback path may produce structural HTML
|
|
54
|
+
return buildDocument(body, options, getRendererCSS(), getRendererJS());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const body = renderJSON(data);
|
|
58
|
+
return buildDocument(body, options, getRendererCSS(), getRendererJS());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Sync structural rendering (no LLM). Use when you know you don't need LLM.
|
|
63
|
+
*/
|
|
64
|
+
export function renderFromDataSync(
|
|
65
|
+
data: unknown,
|
|
66
|
+
options: Omit<DataRenderOptions, 'llm'> = {}
|
|
67
|
+
): string {
|
|
68
|
+
const body = renderJSON(data);
|
|
69
|
+
return buildDocument(body, options, getRendererCSS(), getRendererJS());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Render a form from a JSON Schema (for tool input). Always sync. */
|
|
81
73
|
export function renderFromSchema(
|
|
82
74
|
schema: JSONSchema,
|
|
83
75
|
options: DataRenderOptions = {}
|
|
84
76
|
): string {
|
|
85
|
-
const sniffResult = sniffSchema(schema as Record<string, unknown>);
|
|
86
|
-
|
|
87
77
|
const body = renderForm(schema, {
|
|
88
|
-
...sniffResult.metadata,
|
|
89
78
|
toolName: options.toolName,
|
|
90
79
|
toolDescription: options.toolDescription,
|
|
91
80
|
});
|
|
@@ -94,6 +83,7 @@ export function renderFromSchema(
|
|
|
94
83
|
|
|
95
84
|
const css = [
|
|
96
85
|
generateThemeCSS(),
|
|
86
|
+
generateUtilityCSS(),
|
|
97
87
|
getFormCSS(),
|
|
98
88
|
options.debug ? getPlaygroundCSS() : '',
|
|
99
89
|
].join('\n');
|
|
@@ -111,33 +101,8 @@ export function renderFromSchema(
|
|
|
111
101
|
});
|
|
112
102
|
}
|
|
113
103
|
|
|
114
|
-
/**
|
|
115
|
-
* Render HTML from tool result data.
|
|
116
|
-
*
|
|
117
|
-
* When `options.renderer` is set, that renderer is used directly —
|
|
118
|
-
* the heuristic sniffer is bypassed entirely. This is the recommended
|
|
119
|
-
* path when an LLM (e.g. Claude in Claude Code) has already decided
|
|
120
|
-
* the best visualization strategy.
|
|
121
|
-
*
|
|
122
|
-
* When `options.renderer` is omitted, falls back to the built-in
|
|
123
|
-
* confidence-scored heuristic sniffer for auto-detection.
|
|
124
|
-
*/
|
|
125
|
-
export function renderFromData(
|
|
126
|
-
data: unknown,
|
|
127
|
-
options: DataRenderOptions = {}
|
|
128
|
-
): string {
|
|
129
|
-
if (options.renderer) {
|
|
130
|
-
// LLM-driven path: explicit renderer, no heuristic
|
|
131
|
-
return assembleDocument(options.renderer, data, {}, options);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Fallback: heuristic auto-detection
|
|
135
|
-
const results = sniff(data);
|
|
136
|
-
return assembleDocument(results[0].intent, data, results[0].metadata, options);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
104
|
/** Unified API */
|
|
140
|
-
export function render(input: EngineInput, options: DataRenderOptions = {}): string {
|
|
105
|
+
export async function render(input: EngineInput, options: DataRenderOptions = {}): Promise<string> {
|
|
141
106
|
if (input.mode === 'schema') {
|
|
142
107
|
return renderFromSchema(input.schema, {
|
|
143
108
|
...options,
|
|
@@ -151,22 +116,3 @@ export function render(input: EngineInput, options: DataRenderOptions = {}): str
|
|
|
151
116
|
toolDescription: input.toolDescription,
|
|
152
117
|
});
|
|
153
118
|
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Available renderers — exported for programmatic use.
|
|
157
|
-
* Each renderer takes raw data and returns an HTML fragment (not a full document).
|
|
158
|
-
* Use renderFromData() with `renderer` option for full document output.
|
|
159
|
-
*/
|
|
160
|
-
export const renderers = {
|
|
161
|
-
'data-grid': renderDataGrid,
|
|
162
|
-
'metrics-card': renderMetricsCard,
|
|
163
|
-
'json-tree': renderJsonTree,
|
|
164
|
-
'reading-block': renderReadingBlock,
|
|
165
|
-
'composite': renderComposite,
|
|
166
|
-
'form': renderForm,
|
|
167
|
-
} as const;
|
|
168
|
-
|
|
169
|
-
/** List of available renderer names */
|
|
170
|
-
export const availableRenderers: readonly RenderIntent[] = [
|
|
171
|
-
'data-grid', 'metrics-card', 'json-tree', 'reading-block', 'composite', 'form',
|
|
172
|
-
];
|
package/src/index.ts
CHANGED
|
@@ -3,36 +3,32 @@ export {
|
|
|
3
3
|
render,
|
|
4
4
|
renderFromSchema,
|
|
5
5
|
renderFromData,
|
|
6
|
-
|
|
7
|
-
availableRenderers,
|
|
6
|
+
renderFromDataSync,
|
|
8
7
|
} from './engine.js';
|
|
9
8
|
|
|
10
|
-
//
|
|
11
|
-
export {
|
|
9
|
+
// Universal JSON → HTML renderer (structural fallback)
|
|
10
|
+
export { renderJSON, getRendererCSS, getRendererJS } from './renderer.js';
|
|
11
|
+
|
|
12
|
+
// LLM-powered semantic renderer
|
|
13
|
+
export { renderWithLLM, RENDERING_PROMPT } from './llm-renderer.js';
|
|
14
|
+
export type { LLMConfig } from './llm-renderer.js';
|
|
12
15
|
|
|
13
16
|
// Building blocks (for custom composition)
|
|
14
17
|
export { generateThemeCSS } from './theme.js';
|
|
18
|
+
export { generateUtilityCSS } from './utilities.js';
|
|
15
19
|
export { generateBridgeJS } from './bridge.js';
|
|
16
20
|
export { escapeHtml, tag, style, script, document } from './html-builder.js';
|
|
17
21
|
|
|
18
|
-
//
|
|
19
|
-
export { renderDataGrid, getDataGridCSS, getDataGridJS } from './renderers/data-grid.js';
|
|
20
|
-
export { renderMetricsCard, getMetricsCardCSS } from './renderers/metrics-card.js';
|
|
21
|
-
export { renderJsonTree, getJsonTreeCSS, getJsonTreeJS } from './renderers/json-tree.js';
|
|
22
|
-
export { renderReadingBlock, getReadingBlockCSS } from './renderers/reading-block.js';
|
|
23
|
-
export { renderComposite, getCompositeCSS } from './renderers/composite.js';
|
|
22
|
+
// Form renderer (JSON Schema → form UI)
|
|
24
23
|
export { renderForm, getFormCSS } from './renderers/form.js';
|
|
25
24
|
|
|
26
25
|
// Re-export types
|
|
27
26
|
export type {
|
|
28
|
-
RenderIntent,
|
|
29
|
-
SniffResult,
|
|
30
27
|
RenderOptions,
|
|
31
28
|
EngineInput,
|
|
32
29
|
SchemaInput,
|
|
33
30
|
DataInput,
|
|
34
31
|
JSONSchema,
|
|
35
|
-
Renderer,
|
|
36
32
|
MCPToolDefinition,
|
|
37
33
|
MCPServerInfo,
|
|
38
34
|
} from './types.js';
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM-powered semantic renderer.
|
|
3
|
+
*
|
|
4
|
+
* The structural renderer (renderer.ts) maps JSON shapes to HTML mechanically.
|
|
5
|
+
* This module sends JSON to an LLM with a rendering prompt,
|
|
6
|
+
* letting the model understand semantics and produce the best HTML.
|
|
7
|
+
*
|
|
8
|
+
* Supports any OpenAI-compatible API.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Configuration for the LLM endpoint */
|
|
12
|
+
export interface LLMConfig {
|
|
13
|
+
/** API base URL (e.g. "http://localhost:11434/v1") */
|
|
14
|
+
apiUrl: string;
|
|
15
|
+
/** API key (optional for local models) */
|
|
16
|
+
apiKey?: string;
|
|
17
|
+
/** Model name */
|
|
18
|
+
model: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The rendering prompt.
|
|
23
|
+
*
|
|
24
|
+
* This is intentionally short and non-prescriptive. We tell the model
|
|
25
|
+
* WHAT to do (produce the best HTML for this data), not HOW to do it
|
|
26
|
+
* (don't enumerate SVG, markdown, etc.). The model should figure out
|
|
27
|
+
* the semantics on its own.
|
|
28
|
+
*/
|
|
29
|
+
const SYSTEM_PROMPT = `You are a JSON-to-HTML renderer. You receive JSON data and produce an HTML fragment that visualizes it in the best way possible.
|
|
30
|
+
|
|
31
|
+
Rules:
|
|
32
|
+
1. Output ONLY an HTML fragment. No <html>, <head>, <body> wrappers — those exist already.
|
|
33
|
+
2. Understand what the data MEANS, not just its shape. Render content in its native form — if something is meant to be seen, show it; if it's meant to be read, format it; if it's structured, organize it.
|
|
34
|
+
3. Use these CSS variables for theming (light/dark mode is handled automatically):
|
|
35
|
+
Colors: --bg-primary, --bg-secondary, --bg-tertiary, --bg-elevated, --text-primary, --text-secondary, --text-tertiary, --accent, --accent-subtle, --success, --warning, --danger, --info (each has a -subtle variant), --border, --border-strong
|
|
36
|
+
Typography: --font-sans, --font-mono, --text-xs to --text-3xl
|
|
37
|
+
Spacing: --sp-1 (4px) to --sp-12 (48px)
|
|
38
|
+
Radius: --radius-sm, --radius-md, --radius-lg, --radius-full
|
|
39
|
+
Shadows: --shadow-sm, --shadow-md, --shadow-lg
|
|
40
|
+
Utility classes: .card, .badge, .badge-success, .badge-warning, .badge-danger, .badge-info, .section-title
|
|
41
|
+
4. Tailwind-compatible utility classes are available. Use them freely: flex, grid, gap-*, p-*, m-*, text-*, bg-*, border, rounded-*, shadow-*, font-*, items-*, justify-*, w-full, h-full, overflow-*, relative/absolute, etc. Responsive prefixes sm: and md: work too.
|
|
42
|
+
5. Do not invent data. Do not add commentary. Just the HTML.
|
|
43
|
+
6. Do not wrap output in markdown code fences.`;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Call an OpenAI-compatible chat completions API.
|
|
47
|
+
*/
|
|
48
|
+
async function callLLM(
|
|
49
|
+
data: unknown,
|
|
50
|
+
config: LLMConfig
|
|
51
|
+
): Promise<string> {
|
|
52
|
+
const url = config.apiUrl.replace(/\/+$/, '') + '/chat/completions';
|
|
53
|
+
|
|
54
|
+
const headers: Record<string, string> = {
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
};
|
|
57
|
+
if (config.apiKey) {
|
|
58
|
+
headers['Authorization'] = `Bearer ${config.apiKey}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const body = {
|
|
62
|
+
model: config.model,
|
|
63
|
+
messages: [
|
|
64
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
65
|
+
{ role: 'user', content: JSON.stringify(data, null, 2) },
|
|
66
|
+
],
|
|
67
|
+
temperature: 0.2,
|
|
68
|
+
max_tokens: 16384,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const response = await fetch(url, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers,
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
const text = await response.text();
|
|
79
|
+
throw new Error(`LLM API ${response.status}: ${text.slice(0, 300)}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const result = await response.json() as {
|
|
83
|
+
choices?: Array<{ message?: { content?: string } }>;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const content = result.choices?.[0]?.message?.content;
|
|
87
|
+
if (!content) {
|
|
88
|
+
throw new Error('LLM returned empty response');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return stripCodeFences(content);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Remove markdown code fences if the LLM wrapped its output */
|
|
95
|
+
function stripCodeFences(html: string): string {
|
|
96
|
+
const trimmed = html.trim();
|
|
97
|
+
const fenceMatch = trimmed.match(/^```(?:html)?\s*\n([\s\S]*?)\n\s*```$/);
|
|
98
|
+
if (fenceMatch) {
|
|
99
|
+
return fenceMatch[1].trim();
|
|
100
|
+
}
|
|
101
|
+
return trimmed;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Render JSON data using an LLM for semantic understanding.
|
|
106
|
+
* Returns an HTML fragment (not a full document).
|
|
107
|
+
* Falls back to structural renderer on failure.
|
|
108
|
+
*/
|
|
109
|
+
export async function renderWithLLM(
|
|
110
|
+
data: unknown,
|
|
111
|
+
config: LLMConfig
|
|
112
|
+
): Promise<string> {
|
|
113
|
+
try {
|
|
114
|
+
const html = await callLLM(data, config);
|
|
115
|
+
return `<div class="mcp-root">${html}</div>`;
|
|
116
|
+
} catch (err) {
|
|
117
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
118
|
+
console.error(` LLM rendering failed: ${message}`);
|
|
119
|
+
console.error(' Falling back to structural renderer.');
|
|
120
|
+
|
|
121
|
+
const { renderJSON } = await import('./renderer.js');
|
|
122
|
+
const fallbackHTML = renderJSON(data);
|
|
123
|
+
const errorBanner = `<div style="padding:var(--sp-3);margin-bottom:var(--sp-4);background:var(--warning-subtle);border:1px solid var(--warning);border-radius:var(--radius-sm);font-size:var(--text-sm);color:var(--text-primary)">LLM rendering failed: ${message.replace(/</g, '<')}. Showing structural fallback.</div>`;
|
|
124
|
+
return `<div class="mcp-root">${errorBanner}${fallbackHTML}</div>`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Export the system prompt for inspection/customization */
|
|
129
|
+
export { SYSTEM_PROMPT as RENDERING_PROMPT };
|