@plitzi/sdk-server 0.32.18 → 0.32.20
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 +20 -0
- package/README.md +18 -0
- package/dist/core/services/mcp.js +2 -1
- package/dist/modules/mcp/apps/index.js +7 -3
- package/dist/modules/mcp/apps/render/index.js +5 -4
- package/dist/modules/mcp/apps/render/styles.js +54 -0
- package/dist/modules/mcp/apps/render/view/heldBatch.ts +107 -0
- package/dist/modules/mcp/apps/render/{view.tsx → view/index.tsx} +106 -4
- package/dist/modules/mcp/apps/render/view/streamProgress.ts +137 -0
- package/dist/modules/mcp/apps/shared/assets.js +10 -0
- package/dist/modules/mcp/apps/shared/bundle.js +15 -14
- package/dist/modules/mcp/apps/shared/page.js +17 -6
- package/dist/modules/mcp/apps/shared/registerApp.js +2 -2
- package/dist/modules/mcp/apps/shared/shell.ejs +5 -0
- package/dist/modules/mcp/handler.js +1 -1
- package/dist/modules/mcp/helpers/guide.js +2 -1
- package/dist/modules/mcp/helpers/space.js +13 -1
- package/dist/modules/mcp/resources/register.js +8 -3
- package/dist/modules/mcp/resources/renderGuide.js +63 -11
- package/dist/modules/mcp/resources/router.js +13 -1
- package/dist/modules/mcp/server.js +32 -20
- package/dist/modules/mcp/tools/read.js +21 -3
- package/dist/modules/mcp/tools/render.js +7 -3
- package/dist/modules/mcp/tools/shared/tool.js +16 -11
- package/dist/modules/mcp/tools/shared/validator/elements.js +11 -1
- package/dist/modules/mcp/tools/shared/validator/index.js +9 -5
- package/dist/src/modules/mcp/apps/index.d.ts +6 -3
- package/dist/src/modules/mcp/apps/render/index.d.ts +1 -0
- package/dist/src/modules/mcp/apps/render/styles.d.ts +10 -0
- package/dist/src/modules/mcp/apps/shared/assets.d.ts +10 -0
- package/dist/src/modules/mcp/apps/shared/bundle.d.ts +3 -0
- package/dist/src/modules/mcp/apps/shared/index.d.ts +1 -0
- package/dist/src/modules/mcp/apps/shared/page.d.ts +2 -2
- package/dist/src/modules/mcp/apps/shared/registerApp.d.ts +2 -2
- package/dist/src/modules/mcp/e2e/mcpEndpoint.d.ts +8 -1
- package/dist/src/modules/mcp/e2e/renderingHost.d.ts +5 -0
- package/dist/src/modules/mcp/handler.d.ts +2 -0
- package/dist/src/modules/mcp/helpers/guide.d.ts +1 -0
- package/dist/src/modules/mcp/helpers/space.d.ts +9 -0
- package/dist/src/modules/mcp/resources/index.d.ts +2 -1
- package/dist/src/modules/mcp/resources/register.d.ts +6 -2
- package/dist/src/modules/mcp/resources/renderGuide.d.ts +5 -0
- package/dist/src/modules/mcp/resources/router.d.ts +4 -0
- package/dist/src/modules/mcp/server.d.ts +9 -5
- package/dist/src/modules/mcp/tools/read.d.ts +1 -0
- package/dist/src/modules/mcp/tools/shared/tool.d.ts +8 -0
- package/dist/src/modules/mcp/tools/shared/validator/context.d.ts +4 -0
- package/dist/src/modules/mcp/tools/shared/validator/elements.d.ts +1 -0
- package/dist/src/modules/mcp/tools/shared/validator/index.d.ts +3 -3
- package/dist/src/modules/mcp/types/appTypes.d.ts +9 -1
- package/package.json +6 -5
- package/skills/plitzi-render/SKILL.md +174 -0
- package/dist/src/modules/mcp/apps/render/heldBatch.d.ts +0 -10
- package/dist/src/modules/mcp/apps/render/view.d.ts +0 -1
- /package/dist/modules/mcp/apps/example/{view.tsx → view/index.tsx} +0 -0
- /package/dist/src/modules/mcp/apps/{example/view.d.ts → render/styles.test.d.ts} +0 -0
- /package/dist/src/modules/mcp/{apps/render/heldBatch.test.d.ts → tests/skill.test.d.ts} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @plitzi/sdk-server
|
|
2
2
|
|
|
3
|
+
## 0.32.20
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- v0.32.20
|
|
8
|
+
- Updated dependencies
|
|
9
|
+
- @plitzi/plitzi-sdk@0.32.20
|
|
10
|
+
- @plitzi/sdk-schema@0.32.20
|
|
11
|
+
- @plitzi/sdk-shared@0.32.20
|
|
12
|
+
|
|
13
|
+
## 0.32.19
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- v0.32.19
|
|
18
|
+
- Updated dependencies
|
|
19
|
+
- @plitzi/plitzi-sdk@0.32.19
|
|
20
|
+
- @plitzi/sdk-schema@0.32.19
|
|
21
|
+
- @plitzi/sdk-shared@0.32.19
|
|
22
|
+
|
|
3
23
|
## 0.32.18
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -839,6 +839,24 @@ When `devMode: true`, per-phase timing is instrumented on every render and repor
|
|
|
839
839
|
|
|
840
840
|
In production (`devMode: false`) timing instrumentation is skipped entirely — no `Server-Timing` header, no console output.
|
|
841
841
|
|
|
842
|
+
## Agent skill
|
|
843
|
+
|
|
844
|
+
This package ships an [Agent Skill](https://agentskills.io/) for `plitzi_render`, the MCP tool that renders a
|
|
845
|
+
self-contained UI widget from a batch of operations. The skill teaches an agent when to show a widget instead of
|
|
846
|
+
writing prose, the shape of a good call, the layout/theme traps that make a widget look wrong in a chat panel, and
|
|
847
|
+
how to iterate on a widget it already rendered.
|
|
848
|
+
|
|
849
|
+
It lives in [`skills/plitzi-render`](./skills/plitzi-render/SKILL.md) and is a plain `SKILL.md`, so it installs by
|
|
850
|
+
copying that folder into the skills directory of the agent you use (Claude Code, VS Code / Copilot, Codex, Gemini
|
|
851
|
+
CLI, Cline, Goose…):
|
|
852
|
+
|
|
853
|
+
```bash
|
|
854
|
+
cp -R node_modules/@plitzi/sdk-server/skills/plitzi-render ~/.claude/skills/
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
The skill only pays off with the Plitzi MCP server connected — it defers every detail to the `plitzi://render/guide`
|
|
858
|
+
resource the server publishes, so the two never drift apart.
|
|
859
|
+
|
|
842
860
|
## Exported types
|
|
843
861
|
|
|
844
862
|
```ts
|
|
@@ -8,7 +8,8 @@ var serveMcp = async (ctx) => {
|
|
|
8
8
|
ctx.operation = await handleMcp(ctx.raw, ctx.rawRes, ctx.req, adapters, {
|
|
9
9
|
preview: previewClient ? createHttpPreviewClient(previewClient) : void 0,
|
|
10
10
|
screenshot: screenshot ? createHttpScreenshotClient(screenshot) : void 0,
|
|
11
|
-
logger
|
|
11
|
+
logger,
|
|
12
|
+
renderStreaming: ctx.config.mcpAi?.renderStreaming
|
|
12
13
|
});
|
|
13
14
|
};
|
|
14
15
|
var mcpStage = async (ctx) => {
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { registerApp } from "./shared/registerApp.js";
|
|
2
|
+
import { iconFontCss } from "./render/styles.js";
|
|
2
3
|
import { RENDER_APP_URI, renderApp } from "./render/index.js";
|
|
3
4
|
//#region src/modules/mcp/apps/index.ts
|
|
5
|
+
/** What a view gets when the deployment says nothing: streaming on, because a host that streams no arguments
|
|
6
|
+
* paints exactly what it painted before. */
|
|
7
|
+
var DEFAULT_VIEW_SETTINGS = { streaming: true };
|
|
4
8
|
/** Every MCP App this server serves. A new one is a folder beside `render/` (its definition + its view) and one
|
|
5
9
|
* line here — the tool that opens it points at its `uri` through `ui: { resourceUri }`. Start from `example/`,
|
|
6
10
|
* which is that same shape stripped to the minimum. */
|
|
7
11
|
var apps = [renderApp];
|
|
8
|
-
var registerApps = (server) => {
|
|
9
|
-
for (const app of apps) registerApp(server, app);
|
|
12
|
+
var registerApps = (server, settings = DEFAULT_VIEW_SETTINGS) => {
|
|
13
|
+
for (const app of apps) registerApp(server, app, settings);
|
|
10
14
|
};
|
|
11
15
|
//#endregion
|
|
12
|
-
export { RENDER_APP_URI, apps, registerApps };
|
|
16
|
+
export { DEFAULT_VIEW_SETTINGS, RENDER_APP_URI, apps, iconFontCss, registerApps };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { VIEW_DIR } from "../shared/assets.js";
|
|
2
|
+
import { iconFontCss, widgetCss } from "./styles.js";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
//#region src/modules/mcp/apps/render/index.ts
|
|
@@ -9,8 +10,8 @@ var renderApp = {
|
|
|
9
10
|
name: "plitzi-render-app",
|
|
10
11
|
title: "Plitzi widget",
|
|
11
12
|
description: "Interactive view that renders a plitzi_render widget with the Plitzi SDK.",
|
|
12
|
-
entry: path.join(HERE, "
|
|
13
|
-
styles: () => [
|
|
13
|
+
entry: path.join(HERE, VIEW_DIR, "index.tsx"),
|
|
14
|
+
styles: () => [widgetCss()]
|
|
14
15
|
};
|
|
15
16
|
//#endregion
|
|
16
|
-
export { RENDER_APP_URI, renderApp };
|
|
17
|
+
export { RENDER_APP_URI, iconFontCss, renderApp };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { require } from "../shared/resolve.js";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
//#region src/modules/mcp/apps/render/styles.ts
|
|
5
|
+
/** The SDK stylesheet, split in two.
|
|
6
|
+
*
|
|
7
|
+
* Everything the page shell inlines is paid for by EVERY widget: the host loads the ui:// page into a fresh
|
|
8
|
+
* sandboxed iframe and parses it whole before anything paints, which is the "Rendering…" the user waits through.
|
|
9
|
+
* Three of those @font-face blocks are Font Awesome shipped as base64 woff2 — ~330 KB, close to a fifth of the
|
|
10
|
+
* page — and only a widget that authored a `fontAwesome` element has any use for them.
|
|
11
|
+
*
|
|
12
|
+
* So the shell takes the base, and the fonts travel in the tool result of the renders that actually draw an icon
|
|
13
|
+
* (see tools/render.ts). A widget without icons never downloads a glyph; one with icons looks exactly the same as
|
|
14
|
+
* before. */
|
|
15
|
+
/** Where the closing brace of the block opened at `open` is, so a nested one cannot end it early. Returns the
|
|
16
|
+
* string's length when the CSS is truncated mid-block, which drops that tail rather than mixing it into the base. */
|
|
17
|
+
var blockEnd = (css, open) => {
|
|
18
|
+
let depth = 0;
|
|
19
|
+
for (let at = open; at < css.length; at++) if (css[at] === "{") depth++;
|
|
20
|
+
else if (css[at] === "}") {
|
|
21
|
+
depth--;
|
|
22
|
+
if (depth === 0) return at + 1;
|
|
23
|
+
}
|
|
24
|
+
return css.length;
|
|
25
|
+
};
|
|
26
|
+
/** Lifts every @font-face block out of a stylesheet. Brace counting rather than a CSS parser: a @font-face body is
|
|
27
|
+
* plain declarations, and base64 payloads and url() values carry no braces of their own. */
|
|
28
|
+
var splitFontFaces = (css) => {
|
|
29
|
+
const base = [];
|
|
30
|
+
const fonts = [];
|
|
31
|
+
let cursor = 0;
|
|
32
|
+
for (let at = css.indexOf("@font-face", cursor); at !== -1; at = css.indexOf("@font-face", cursor)) {
|
|
33
|
+
const open = css.indexOf("{", at);
|
|
34
|
+
if (open === -1) break;
|
|
35
|
+
const end = blockEnd(css, open);
|
|
36
|
+
base.push(css.slice(cursor, at));
|
|
37
|
+
fonts.push(css.slice(at, end));
|
|
38
|
+
cursor = end;
|
|
39
|
+
}
|
|
40
|
+
base.push(css.slice(cursor));
|
|
41
|
+
return {
|
|
42
|
+
base: base.join(""),
|
|
43
|
+
fonts: fonts.join("")
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
var stylesheet = () => readFileSync(path.join(path.dirname(require.resolve("@plitzi/plitzi-sdk")), "plitzi-sdk.css"), "utf-8");
|
|
47
|
+
var split;
|
|
48
|
+
var sdkStyles = () => split ??= splitFontFaces(stylesheet());
|
|
49
|
+
/** What the page shell inlines: the SDK stylesheet without the icon fonts. */
|
|
50
|
+
var widgetCss = () => sdkStyles().base;
|
|
51
|
+
/** The icon fonts, for the renders that draw one. */
|
|
52
|
+
var iconFontCss = () => sdkStyles().fonts;
|
|
53
|
+
//#endregion
|
|
54
|
+
export { iconFontCss, splitFontFaces, widgetCss };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Where the batch a widget was built from survives between calls — on the HOST, so the server can keep nothing.
|
|
2
|
+
*
|
|
3
|
+
* It cannot live in the view's memory: the Apps spec binds a view to ONE tool call (`ui/notifications/tool-input`
|
|
4
|
+
* is sent "at most once", and the host renders a fresh iframe when a UI tool is called), so the patch call the
|
|
5
|
+
* model makes arrives in a brand-new instance whose memory is empty. localStorage keyed by an id the server hands
|
|
6
|
+
* back is the spec's own state-persistence pattern (`_meta.viewUUID` in the Apps docs); here the key is the
|
|
7
|
+
* renderId, which the model also carries, so a patch names the widget it means instead of assuming "the last one".
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const PREFIX = 'plitzi.render.';
|
|
11
|
+
const MAX_ENTRIES = 5;
|
|
12
|
+
// A renderId only lives in one conversation's context, so a batch outlives its usefulness the moment that
|
|
13
|
+
// conversation is over — and a conversation the user abandoned would otherwise leave its widget in the host's
|
|
14
|
+
// storage for good. Generous enough that no live conversation loses its widget, short enough to be housekeeping.
|
|
15
|
+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
type Stored = { savedAt: number; operations: unknown[] };
|
|
18
|
+
|
|
19
|
+
// A sandboxed iframe without allow-same-origin throws on the ACCESS to localStorage, not on the call — so even
|
|
20
|
+
// reading the property has to be guarded. A host that denies storage simply gets no patching.
|
|
21
|
+
const localStore = (): Storage | undefined => {
|
|
22
|
+
try {
|
|
23
|
+
return window.localStorage;
|
|
24
|
+
} catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const parse = (raw: string | null): Stored | undefined => {
|
|
30
|
+
if (raw === null) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const value = JSON.parse(raw) as Stored;
|
|
36
|
+
|
|
37
|
+
return Array.isArray(value.operations) ? value : undefined;
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const ownKeys = (store: Storage): string[] => {
|
|
44
|
+
const keys: string[] = [];
|
|
45
|
+
for (let index = 0; index < store.length; index += 1) {
|
|
46
|
+
const key = store.key(index);
|
|
47
|
+
if (key !== null && key.startsWith(PREFIX)) {
|
|
48
|
+
keys.push(key);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return keys;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const dropStale = (store: Storage, keep: string): void => {
|
|
56
|
+
const others = ownKeys(store)
|
|
57
|
+
.filter(key => key !== keep)
|
|
58
|
+
.map(key => ({ key, savedAt: parse(store.getItem(key))?.savedAt ?? 0 }))
|
|
59
|
+
.sort((a, b) => b.savedAt - a.savedAt);
|
|
60
|
+
const expired = Date.now() - MAX_AGE_MS;
|
|
61
|
+
|
|
62
|
+
for (const [index, entry] of others.entries()) {
|
|
63
|
+
if (index >= MAX_ENTRIES - 1 || entry.savedAt < expired) {
|
|
64
|
+
store.removeItem(entry.key);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const readHeldBatch = (renderId: string, store = localStore()): unknown[] | undefined => {
|
|
70
|
+
if (!store) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const stored = parse(store.getItem(`${PREFIX}${renderId}`));
|
|
75
|
+
if (!stored || stored.savedAt < Date.now() - MAX_AGE_MS) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return stored.operations;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const writeHeldBatch = (renderId: string, operations: unknown[], store = localStore()): void => {
|
|
83
|
+
if (!store) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const key = `${PREFIX}${renderId}`;
|
|
88
|
+
const payload = JSON.stringify({ savedAt: Date.now(), operations } satisfies Stored);
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
dropStale(store, key);
|
|
92
|
+
store.setItem(key, payload);
|
|
93
|
+
} catch {
|
|
94
|
+
// Out of quota: the widget just rendered is the only one still worth patching, so the rest go.
|
|
95
|
+
for (const other of ownKeys(store)) {
|
|
96
|
+
if (other !== key) {
|
|
97
|
+
store.removeItem(other);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
store.setItem(key, payload);
|
|
103
|
+
} catch {
|
|
104
|
+
// Storage is full or refused: patching degrades to "re-send the full batch", never to a wrong widget.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
@@ -5,7 +5,9 @@ import { Component, useEffect, useRef, useState } from 'react';
|
|
|
5
5
|
import { createRoot } from 'react-dom/client';
|
|
6
6
|
|
|
7
7
|
import { readHeldBatch, writeHeldBatch } from './heldBatch';
|
|
8
|
+
import { mergeProgress, streamProgress } from './streamProgress';
|
|
8
9
|
|
|
10
|
+
import type { StreamProgress } from './streamProgress';
|
|
9
11
|
import type { App, McpUiHostContext } from '@modelcontextprotocol/ext-apps';
|
|
10
12
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
11
13
|
import type { OfflineDataRaw } from '@plitzi/sdk-shared';
|
|
@@ -25,6 +27,62 @@ const ErrorPanel = ({ title, details }: { title: string; details: string }) => (
|
|
|
25
27
|
</div>
|
|
26
28
|
);
|
|
27
29
|
|
|
30
|
+
/** The wait a user reads as "this is slow" is the model TYPING the batch: the tool only runs once the last brace
|
|
31
|
+
* is written, and until then the page shows its static "Rendering…". The host streams the arguments as they
|
|
32
|
+
* arrive, so this stands in for the widget meanwhile — bars that grow with the elements already authored, and the
|
|
33
|
+
* widget's own title as soon as the batch names it. It never shows a half-built widget: the real one replaces it
|
|
34
|
+
* wholesale when the result lands. A host that streams nothing paints none of this and keeps the static text. */
|
|
35
|
+
const SKELETON_BAR_WIDTHS = ['92%', '78%', '85%', '64%', '88%', '72%'];
|
|
36
|
+
|
|
37
|
+
// The deployment's own switch (mcpAi.renderStreaming), handed over by the page. Absent means on: a page built
|
|
38
|
+
// before the setting existed carries none, and a host that streams no arguments paints the same either way.
|
|
39
|
+
const streamingEnabled =
|
|
40
|
+
(globalThis as { __PLITZI_VIEW__?: { streaming?: boolean } }).__PLITZI_VIEW__?.streaming !== false;
|
|
41
|
+
|
|
42
|
+
const skeletonBarStyle = {
|
|
43
|
+
height: 10,
|
|
44
|
+
borderRadius: 5,
|
|
45
|
+
backgroundColor: 'var(--color-background-tertiary, light-dark(#e2e8f0, #333a48))',
|
|
46
|
+
animation: 'plitzi-skeleton-pulse 1.4s ease-in-out infinite'
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
// One bar per few elements, so the block visibly grows while the model writes, and a ceiling so a 200-element
|
|
50
|
+
// widget does not fill the panel with grey. Total by construction: this paints a wait, and a placeholder that
|
|
51
|
+
// renders nothing at all (an empty slice off a NaN) would read as the view having died.
|
|
52
|
+
const barCount = (elements: number): number => {
|
|
53
|
+
const bars = Math.ceil(elements / 3);
|
|
54
|
+
|
|
55
|
+
return Number.isFinite(bars) ? Math.min(Math.max(bars, 2), 6) : 2;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const StreamSkeleton = ({ progress }: { progress: StreamProgress }) => {
|
|
59
|
+
const caption = progress.patch ? 'Updating the widget…' : 'Building the widget…';
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
role="status"
|
|
64
|
+
aria-live="polite"
|
|
65
|
+
style={{
|
|
66
|
+
display: 'flex',
|
|
67
|
+
flexDirection: 'column',
|
|
68
|
+
gap: 10,
|
|
69
|
+
padding: 16,
|
|
70
|
+
font: '13px/1.5 var(--font-sans, system-ui, sans-serif)'
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<style>{'@keyframes plitzi-skeleton-pulse { 0%, 100% { opacity: 0.5 } 50% { opacity: 1 } }'}</style>
|
|
74
|
+
{progress.title && <strong style={{ fontSize: 15 }}>{progress.title}</strong>}
|
|
75
|
+
{SKELETON_BAR_WIDTHS.slice(0, barCount(progress.elements)).map(width => (
|
|
76
|
+
<div key={width} style={{ ...skeletonBarStyle, width }} />
|
|
77
|
+
))}
|
|
78
|
+
<span style={{ color: 'var(--color-text-tertiary, light-dark(#64748b, #9aa4b2))' }}>
|
|
79
|
+
{caption}
|
|
80
|
+
{progress.elements > 0 && ` ${progress.elements} elements so far`}
|
|
81
|
+
</span>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
28
86
|
class RenderBoundary extends Component<{ children: ReactNode }, { error?: Error }> {
|
|
29
87
|
state: { error?: Error } = {};
|
|
30
88
|
|
|
@@ -56,10 +114,36 @@ const summarise = (result: CallToolResult): string => {
|
|
|
56
114
|
|
|
57
115
|
const RenderApp = () => {
|
|
58
116
|
const [result, setResult] = useState<CallToolResult | null>(null);
|
|
117
|
+
const [progress, setProgress] = useState<StreamProgress | undefined>(undefined);
|
|
59
118
|
const [cancelled, setCancelled] = useState<string | undefined>(undefined);
|
|
60
119
|
const [context, setContext] = useState<McpUiHostContext | undefined>(undefined);
|
|
61
120
|
const held = useRef<Held>({ operations: [] });
|
|
62
121
|
const appRef = useRef<App | null>(null);
|
|
122
|
+
// The handlers are registered once, so they close over the FIRST render's state: a ref is the only way they can
|
|
123
|
+
// tell whether a widget is already on screen.
|
|
124
|
+
const painted = useRef(false);
|
|
125
|
+
|
|
126
|
+
const showWidget = (toolResult: CallToolResult): void => {
|
|
127
|
+
painted.current = true;
|
|
128
|
+
setResult(toolResult);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Streamed arguments are the one input here nobody validated: healed JSON, from a host, about a call that does
|
|
132
|
+
* not exist yet. So it is fenced off — it feeds the placeholder and NOTHING else (never the held batch, never
|
|
133
|
+
* storage, never the server), it cannot run once a widget is up, and it cannot throw: a notification handler
|
|
134
|
+
* that raises would break the App's dispatch, and this one exists only to decorate a wait. */
|
|
135
|
+
const trackInput = (args: unknown): void => {
|
|
136
|
+
if (painted.current) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const next = streamProgress(args);
|
|
142
|
+
setProgress(previous => mergeProgress(previous, next));
|
|
143
|
+
} catch {
|
|
144
|
+
// A placeholder that stops growing is a non-event; the widget is still on its way.
|
|
145
|
+
}
|
|
146
|
+
};
|
|
63
147
|
|
|
64
148
|
// A patch carries only what changed. Merging it onto the held batch and re-calling the tool is what keeps the
|
|
65
149
|
// server stateless: it re-renders the WHOLE widget (so refs, integrity and the audit are all checked as usual)
|
|
@@ -98,7 +182,7 @@ const RenderApp = () => {
|
|
|
98
182
|
const applied = (rendered.structuredContent?.operations as unknown[] | undefined) ?? merged;
|
|
99
183
|
held.current = { renderId, operations: applied };
|
|
100
184
|
writeHeldBatch(renderId, applied);
|
|
101
|
-
|
|
185
|
+
showWidget(rendered);
|
|
102
186
|
}
|
|
103
187
|
|
|
104
188
|
await app.updateModelContext({
|
|
@@ -131,6 +215,15 @@ const RenderApp = () => {
|
|
|
131
215
|
capabilities: {},
|
|
132
216
|
onAppCreated: instance => {
|
|
133
217
|
appRef.current = instance;
|
|
218
|
+
// Streaming arguments (sent zero or more times while the model writes) and then the complete ones, which the
|
|
219
|
+
// host MUST send before the result: the last frame the placeholder gets covers the server render itself.
|
|
220
|
+
// The `on*` setters are the deprecated half of the API, kept because every other handler here uses them —
|
|
221
|
+
// mixing addEventListener for one of them would hide the lifecycle this component is built around.
|
|
222
|
+
if (streamingEnabled) {
|
|
223
|
+
instance.ontoolinputpartial = params => trackInput(params.arguments);
|
|
224
|
+
instance.ontoolinput = params => trackInput(params.arguments);
|
|
225
|
+
}
|
|
226
|
+
|
|
134
227
|
instance.ontoolresult = toolResult => {
|
|
135
228
|
const renderId = toolResult.structuredContent?.renderId as string | undefined;
|
|
136
229
|
if (toolResult.structuredContent?.patch === true && renderId) {
|
|
@@ -152,7 +245,7 @@ const RenderApp = () => {
|
|
|
152
245
|
writeHeldBatch(renderId, operations);
|
|
153
246
|
}
|
|
154
247
|
|
|
155
|
-
|
|
248
|
+
showWidget(toolResult);
|
|
156
249
|
};
|
|
157
250
|
instance.ontoolcancelled = params => setCancelled(params.reason ?? 'The host cancelled the render.');
|
|
158
251
|
instance.onhostcontextchanged = params => setContext(previous => ({ ...previous, ...params }));
|
|
@@ -174,8 +267,13 @@ const RenderApp = () => {
|
|
|
174
267
|
return <ErrorPanel title="Could not render the widget" details={failure} />;
|
|
175
268
|
}
|
|
176
269
|
|
|
177
|
-
//
|
|
270
|
+
// Waiting for the result. Once the host has streamed any of the arguments the skeleton stands in for the widget
|
|
271
|
+
// being written; before that (and on a host that streams nothing) the page's own CSS placeholder covers it.
|
|
178
272
|
if (!result) {
|
|
273
|
+
if (progress) {
|
|
274
|
+
return <StreamSkeleton progress={progress} />;
|
|
275
|
+
}
|
|
276
|
+
|
|
179
277
|
return null;
|
|
180
278
|
}
|
|
181
279
|
|
|
@@ -190,6 +288,9 @@ const RenderApp = () => {
|
|
|
190
288
|
}
|
|
191
289
|
|
|
192
290
|
const insets = context?.safeAreaInsets;
|
|
291
|
+
// The icon fonts, ~330 KB of base64 woff2, are not in the page: they arrive with the widgets that draw an icon,
|
|
292
|
+
// so every other widget's iframe has that much less to load before it paints (see ../styles.ts).
|
|
293
|
+
const iconCss = result.structuredContent?.iconCss as string | undefined;
|
|
193
294
|
|
|
194
295
|
return (
|
|
195
296
|
<div
|
|
@@ -197,8 +298,9 @@ const RenderApp = () => {
|
|
|
197
298
|
padding: `${insets?.top ?? 0}px ${insets?.right ?? 0}px ${insets?.bottom ?? 0}px ${insets?.left ?? 0}px`
|
|
198
299
|
}}
|
|
199
300
|
>
|
|
301
|
+
{iconCss && <style>{iconCss}</style>}
|
|
200
302
|
<RenderBoundary>
|
|
201
|
-
<PlitziSdk offlineData={offlineData} offlineMode environment="main" renderMode="raw" />
|
|
303
|
+
<PlitziSdk offlineData={offlineData} offlineMode environment="main" renderMode="raw" branding={false} />
|
|
202
304
|
</RenderBoundary>
|
|
203
305
|
</div>
|
|
204
306
|
);
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/** What the host has streamed of the tool call so far, reduced to the little a placeholder can honestly show.
|
|
2
|
+
*
|
|
3
|
+
* The wait a user sees is almost entirely the model TYPING this batch — the server render takes milliseconds —
|
|
4
|
+
* so the only way to look alive is to read the arguments while they are still arriving. The host sends them as
|
|
5
|
+
* "healed" JSON (it closes the braces the model has not written yet), which means every field may be missing and
|
|
6
|
+
* the last operation is usually half-written: nothing here may assume a shape, and a fragment it cannot read
|
|
7
|
+
* simply contributes nothing.
|
|
8
|
+
*
|
|
9
|
+
* Two properties this module owes the view, because a placeholder that misbehaves is worse than none:
|
|
10
|
+
* - it is TOTAL — no input throws, and every number it returns is a finite count;
|
|
11
|
+
* - it only ever moves FORWARD (see mergeProgress) — healing is best-effort and the spec warns that fields may
|
|
12
|
+
* change between notifications, so a frame that recovers less than the one before it must not shrink the
|
|
13
|
+
* placeholder, which the eye reads as flicker. */
|
|
14
|
+
export type StreamProgress = { elements: number; title?: string; patch: boolean };
|
|
15
|
+
|
|
16
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
17
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
+
|
|
19
|
+
const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : []);
|
|
20
|
+
|
|
21
|
+
// The walk is over a structure this code did not build, so it is bounded rather than trusted: a pathological
|
|
22
|
+
// nesting would otherwise turn a placeholder into a stack overflow, which kills the view for good — including
|
|
23
|
+
// the widget that was on its way. Far beyond anything a real widget nests.
|
|
24
|
+
const MAX_DEPTH = 64;
|
|
25
|
+
|
|
26
|
+
// A title is a label in a small panel: whitespace collapsed so a multi-line heading cannot push the bars off
|
|
27
|
+
// screen, and cut where it stops being a label.
|
|
28
|
+
const MAX_TITLE = 80;
|
|
29
|
+
|
|
30
|
+
// A repeat renders its template once per row, so the rows are what the user will actually count on screen. An
|
|
31
|
+
// items list that has not started streaming yet still stands for at least the one template being written.
|
|
32
|
+
const rowsOf = (items: unknown): number => Math.max(asArray(items).length, 1);
|
|
33
|
+
|
|
34
|
+
const countElements = (node: unknown, depth = 0): number => {
|
|
35
|
+
if (!isRecord(node) || depth > MAX_DEPTH) {
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const repeat = isRecord(node.repeat) ? node.repeat : undefined;
|
|
40
|
+
const nested = repeat ? countElements(repeat.template, depth + 1) * rowsOf(repeat.items) : 0;
|
|
41
|
+
|
|
42
|
+
return asArray(node.children).reduce<number>((total, child) => total + countElements(child, depth + 1), 1 + nested);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// A heading's content arrives one fragment at a time, so this runs against half-written text: `{{` rules out a
|
|
46
|
+
// repeat template's placeholder (a literal "{{item.name}}" on screen reads as a bug, not as progress), and an
|
|
47
|
+
// empty string is what a heading looks like before its content exists at all.
|
|
48
|
+
const titleOf = (content: unknown): string | undefined => {
|
|
49
|
+
if (typeof content !== 'string' || content.includes('{{')) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const text = content.replace(/\s+/gu, ' ').trim();
|
|
54
|
+
if (text === '') {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return text.length > MAX_TITLE ? `${text.slice(0, MAX_TITLE)}…` : text;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// The first heading the batch declares is the widget's own title far more often than not, so showing it turns the
|
|
62
|
+
// placeholder from "something is happening" into "your pricing table is coming".
|
|
63
|
+
const findTitle = (node: unknown, depth = 0): string | undefined => {
|
|
64
|
+
if (!isRecord(node) || depth > MAX_DEPTH) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const props = isRecord(node.props) ? node.props : undefined;
|
|
69
|
+
if (node.type === 'heading') {
|
|
70
|
+
const title = titleOf(props?.content);
|
|
71
|
+
if (title !== undefined) {
|
|
72
|
+
return title;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const child of asArray(node.children)) {
|
|
77
|
+
const found = findTitle(child, depth + 1);
|
|
78
|
+
if (found !== undefined) {
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return undefined;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// An op either builds a tree (upsertElement) or repeats one (repeatElement); anything else — a definition, a
|
|
87
|
+
// binding, the truncated tail — adds no elements to count.
|
|
88
|
+
const treeOf = (op: Record<string, unknown>): unknown => {
|
|
89
|
+
if (isRecord(op.element)) {
|
|
90
|
+
return op.element;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return isRecord(op.template) ? op.template : undefined;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const streamProgress = (args: unknown): StreamProgress => {
|
|
97
|
+
const record = isRecord(args) ? args : {};
|
|
98
|
+
let elements = 0;
|
|
99
|
+
let title: string | undefined;
|
|
100
|
+
|
|
101
|
+
for (const op of asArray(record.operations)) {
|
|
102
|
+
if (!isRecord(op)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const tree = treeOf(op);
|
|
107
|
+
if (tree === undefined) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
elements += countElements(tree) * (isRecord(op.template) ? rowsOf(op.items) : 1);
|
|
112
|
+
title ??= findTitle(tree);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { elements: Number.isFinite(elements) ? elements : 0, title, patch: record.patch === true };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Fold a freshly read frame into what the placeholder already shows. Monotonic on purpose — the count never goes
|
|
119
|
+
* down and a title, once read, is never taken away — because the healed JSON of frame N+1 can legitimately
|
|
120
|
+
* recover LESS than frame N, and a placeholder that shrinks and grows is the flicker this whole thing exists to
|
|
121
|
+
* avoid. Returns the previous object unchanged when nothing moved, so an unchanged frame costs no re-render. */
|
|
122
|
+
export const mergeProgress = (previous: StreamProgress | undefined, next: StreamProgress): StreamProgress => {
|
|
123
|
+
if (!previous) {
|
|
124
|
+
return next;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const merged: StreamProgress = {
|
|
128
|
+
elements: Math.max(previous.elements, next.elements),
|
|
129
|
+
title: next.title ?? previous.title,
|
|
130
|
+
patch: previous.patch || next.patch
|
|
131
|
+
};
|
|
132
|
+
if (merged.elements === previous.elements && merged.title === previous.title && merged.patch === previous.patch) {
|
|
133
|
+
return previous;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return merged;
|
|
137
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
//#region src/modules/mcp/apps/shared/assets.ts
|
|
3
|
+
/** The one folder of an app that ships to dist as SOURCE: its browser entry (`view/index.tsx`) and every module
|
|
4
|
+
* that entry bundles. The server builds it with esbuild at request time, so none of it is in the module graph the
|
|
5
|
+
* library build compiles — a view-side module that does not travel verbatim is simply missing at runtime, which
|
|
6
|
+
* the package only finds out when a host asks for the page. Everything else in an app (its definition) compiles
|
|
7
|
+
* like any other module. */
|
|
8
|
+
var VIEW_DIR = "view";
|
|
9
|
+
//#endregion
|
|
10
|
+
export { VIEW_DIR };
|
|
@@ -2,21 +2,22 @@ import { require } from "./resolve.js";
|
|
|
2
2
|
import { zodEnglishOnly } from "./zodEnglishOnly.js";
|
|
3
3
|
import { build } from "esbuild";
|
|
4
4
|
//#region src/modules/mcp/apps/shared/bundle.ts
|
|
5
|
+
var options = (entry) => ({
|
|
6
|
+
entryPoints: [entry],
|
|
7
|
+
bundle: true,
|
|
8
|
+
write: false,
|
|
9
|
+
format: "iife",
|
|
10
|
+
platform: "browser",
|
|
11
|
+
target: "es2022",
|
|
12
|
+
jsx: "automatic",
|
|
13
|
+
minify: true,
|
|
14
|
+
alias: { "@plitzi/plitzi-sdk": require.resolve("@plitzi/plitzi-sdk") },
|
|
15
|
+
define: { "process.env.NODE_ENV": "\"production\"" },
|
|
16
|
+
plugins: [zodEnglishOnly],
|
|
17
|
+
logLevel: "silent"
|
|
18
|
+
});
|
|
5
19
|
var bundle = async (entry) => {
|
|
6
|
-
return (await build(
|
|
7
|
-
entryPoints: [entry],
|
|
8
|
-
bundle: true,
|
|
9
|
-
write: false,
|
|
10
|
-
format: "iife",
|
|
11
|
-
platform: "browser",
|
|
12
|
-
target: "es2022",
|
|
13
|
-
jsx: "automatic",
|
|
14
|
-
minify: true,
|
|
15
|
-
alias: { "@plitzi/plitzi-sdk": require.resolve("@plitzi/plitzi-sdk") },
|
|
16
|
-
define: { "process.env.NODE_ENV": "\"production\"" },
|
|
17
|
-
plugins: [zodEnglishOnly],
|
|
18
|
-
logLevel: "silent"
|
|
19
|
-
})).outputFiles[0].text;
|
|
20
|
+
return (await build(options(entry))).outputFiles[0].text;
|
|
20
21
|
};
|
|
21
22
|
//#endregion
|
|
22
23
|
export { bundle };
|
|
@@ -6,16 +6,27 @@ import ejs from "ejs";
|
|
|
6
6
|
//#region src/modules/mcp/apps/shared/page.ts
|
|
7
7
|
var SHELL = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "shell.ejs");
|
|
8
8
|
var template = () => ejs.compile(readFileSync(SHELL, "utf-8"), { filename: SHELL });
|
|
9
|
+
var scripts = /* @__PURE__ */ new Map();
|
|
9
10
|
var pages = /* @__PURE__ */ new Map();
|
|
10
|
-
var
|
|
11
|
-
let
|
|
11
|
+
var script = (entry) => {
|
|
12
|
+
let built = scripts.get(entry);
|
|
13
|
+
if (!built) {
|
|
14
|
+
built = bundle(entry);
|
|
15
|
+
scripts.set(entry, built);
|
|
16
|
+
}
|
|
17
|
+
return built;
|
|
18
|
+
};
|
|
19
|
+
var page = (app, settings) => {
|
|
20
|
+
const key = `${app.uri}|${JSON.stringify(settings)}`;
|
|
21
|
+
let html = pages.get(key);
|
|
12
22
|
if (!html) {
|
|
13
|
-
html =
|
|
23
|
+
html = script(app.entry).then((code) => template()({
|
|
14
24
|
title: app.title,
|
|
15
|
-
app:
|
|
16
|
-
css: (app.styles?.() ?? []).
|
|
25
|
+
app: code,
|
|
26
|
+
css: (app.styles?.() ?? []).join("\n"),
|
|
27
|
+
settings: JSON.stringify(settings)
|
|
17
28
|
}));
|
|
18
|
-
pages.set(
|
|
29
|
+
pages.set(key, html);
|
|
19
30
|
}
|
|
20
31
|
return html;
|
|
21
32
|
};
|