@aibranch/llm-meta-widget 0.8.6 → 0.9.1
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/README.md
CHANGED
|
@@ -216,6 +216,7 @@ easy to get wrong:
|
|
|
216
216
|
| `llm-provider` | `llm_meta_server` (default) or `ollama` |
|
|
217
217
|
| `api-key-uuid`, `greeting`, `max-rounds` | as the helper options |
|
|
218
218
|
| `actions-schema-id`, `state-global`, `actions-global`, `remote-tools-schema-id` | as the helper options |
|
|
219
|
+
| `page-card-id`, `prompts-global`, `resources-global` | as the helper options |
|
|
219
220
|
| `models`, `hub-tools` | comma-separated. Omitted **or empty** means no allowlist — an allowlist permitting nothing is never what anyone meant |
|
|
220
221
|
| `enable-model-picker`, `enable-tool-picker` | **value attributes, not boolean attributes.** They default to true, so presence cannot mean true. Disable with `enable-tool-picker="false"`; any other value is true |
|
|
221
222
|
| `well-known-urls` | **Three different states**, because an attribute cannot tell "not set" from "set to empty": omitted = auto-discover same-origin `/.well-known/mcp.json`; `""` = discovery off; `"a,b"` = fetch those |
|
|
@@ -401,6 +402,9 @@ translate directly.
|
|
|
401
402
|
| `api_key_uuid:` | `"ollama-local"` | Hub API-key uuid to invoke |
|
|
402
403
|
| `element_path:` | `"/llm_meta_widget_assets/llm-meta-widget.js"` | The bundled element the page loads. Served by the gem's engine; override to load it from elsewhere |
|
|
403
404
|
| `actions_schema_id:` | `"ai-actions"` | DOM id of the Class-3 schema block |
|
|
405
|
+
| `page_card_id:` | `"ai-page"` | DOM id of the page card (see "The page card"). Its tools replace the `actions_schema_id` block |
|
|
406
|
+
| `prompts_global:` | `"aiPrompts"` | Global object holding page prompt handlers, keyed by prompt name |
|
|
407
|
+
| `resources_global:` | `"aiResources"` | Global object holding page resource readers, keyed by resource URI |
|
|
404
408
|
| `state_global:` | `"aiState"` | Global window object holding Class-3 state readers, each `{ description, read }` (see the breaking change in 0.8.0) |
|
|
405
409
|
| `actions_global:` | `"aiActions"` | Global window object holding Class-3 implementations |
|
|
406
410
|
| `remote_tools_schema_id:` | `"remote-mcp-tools"` | DOM id of a JSON block listing Class-1 tools (`{id, name, description, input_schema}`) to start with. They seed the picker rather than bypassing it — shown ticked, and the visitor may untick them. Read once, at boot |
|
|
@@ -412,6 +416,59 @@ translate directly.
|
|
|
412
416
|
| `models:` | `nil` | Model-name allowlist; `nil` = all anon-available |
|
|
413
417
|
| `hub_tools:` | `nil` | MCP-server-name allowlist; `nil` = all anon-public |
|
|
414
418
|
|
|
419
|
+
## The page card
|
|
420
|
+
|
|
421
|
+
Since 0.9.0 a page can declare what it offers in one block of JSON, instead of
|
|
422
|
+
using a separate channel for each kind of thing. The block uses the same envelope
|
|
423
|
+
an MCP server puts in its Server Card, so the page and the server describe
|
|
424
|
+
themselves the same way.
|
|
425
|
+
|
|
426
|
+
```html
|
|
427
|
+
<script type="application/json" id="ai-page">
|
|
428
|
+
{ "_meta": { "io.modelcontextprotocol/static-primitives": {
|
|
429
|
+
"tools": [ { "name": "set_text", "description": "…", "inputSchema": { } } ],
|
|
430
|
+
"prompts": [ { "name": "annotate", "description": "Annotate this page" } ],
|
|
431
|
+
"resources": [ { "uri": "page:text", "name": "Text", "description": "the text to annotate",
|
|
432
|
+
"volatility": "volatile" } ]
|
|
433
|
+
} } }
|
|
434
|
+
</script>
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
The card says what exists. Three global objects say what it does, and each one is
|
|
438
|
+
keyed by the identifier that the matching MCP operation takes:
|
|
439
|
+
|
|
440
|
+
| declared in the card | implemented in | keyed by |
|
|
441
|
+
|---|---|---|
|
|
442
|
+
| `tools` | `window.aiActions` | tool `name`, as `tools/call` takes |
|
|
443
|
+
| `prompts` | `window.aiPrompts` | prompt `name`, as `prompts/get` takes |
|
|
444
|
+
| `resources` | `window.aiResources` | resource `uri`, as `resources/read` takes |
|
|
445
|
+
|
|
446
|
+
```js
|
|
447
|
+
window.aiActions = { set_text: (args) => { /* change the page */ } }
|
|
448
|
+
window.aiPrompts = { annotate: (args) => "Annotate this: " + args.text }
|
|
449
|
+
window.aiResources = { "page:text": () => document.querySelector("#text").value }
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
A prompt handler may return a plain string or the full `messages` array that
|
|
453
|
+
`prompts/get` returns. A resource reader may return a string or any value that
|
|
454
|
+
can be turned into JSON.
|
|
455
|
+
|
|
456
|
+
**Resources carry two optional hints.** `volatility` is `"volatile"` when the
|
|
457
|
+
value changes between turns, so the widget reads it again every turn; the default
|
|
458
|
+
is `"stable"`, which is read once and reused. `autoAttach: false` keeps a resource
|
|
459
|
+
out of the turn unless something asks for it. A `size` in bytes lets the widget
|
|
460
|
+
decide whether the value fits its budget before reading it.
|
|
461
|
+
|
|
462
|
+
**The `page:` scheme is experimental.** It is not a registered URI scheme, and
|
|
463
|
+
nothing outside this widget gives it meaning. It is used because the protocol
|
|
464
|
+
asks for a URI, and because `https://` is reserved for resources a client can
|
|
465
|
+
fetch from the web by itself — which page resources are not. The form is
|
|
466
|
+
`page:<name>` with no path, because a page has only one namespace. Expect this to
|
|
467
|
+
change.
|
|
468
|
+
|
|
469
|
+
**The card is optional.** A page that declares nothing keeps working exactly as
|
|
470
|
+
before, and a page that declares no `tools` still uses its `ai-actions` block.
|
|
471
|
+
|
|
415
472
|
## Surviving navigation
|
|
416
473
|
|
|
417
474
|
A page action that navigates — submitting a form, following a link — no longer
|
|
@@ -483,6 +483,17 @@ async function readMcpResource({ endpoint, uri, signal }) {
|
|
|
483
483
|
label: `readMcpResource(${uri})`
|
|
484
484
|
});
|
|
485
485
|
}
|
|
486
|
+
async function getPagePrompt({ name, args, prompts }) {
|
|
487
|
+
const handler = prompts && prompts[name];
|
|
488
|
+
if (typeof handler !== "function") {
|
|
489
|
+
throw new Error('the page declares prompt "' + name + '" but no handler answers to that name');
|
|
490
|
+
}
|
|
491
|
+
const result = await handler(args || {});
|
|
492
|
+
if (typeof result === "string") {
|
|
493
|
+
return { messages: [{ role: "user", content: { type: "text", text: result } }] };
|
|
494
|
+
}
|
|
495
|
+
return result || { messages: [] };
|
|
496
|
+
}
|
|
486
497
|
function promptMessagesToText(result) {
|
|
487
498
|
return (result?.messages || []).map((m) => {
|
|
488
499
|
const c = m.content;
|
|
@@ -549,8 +560,40 @@ function parseSseFrame(raw) {
|
|
|
549
560
|
return { name, data };
|
|
550
561
|
}
|
|
551
562
|
var STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives";
|
|
563
|
+
function readStaticPrimitivesCard(text, warn = console.warn) {
|
|
564
|
+
const empty = { tools: [], prompts: [], resources: [] };
|
|
565
|
+
if (typeof text !== "string" || text.trim() === "") return empty;
|
|
566
|
+
let doc;
|
|
567
|
+
try {
|
|
568
|
+
doc = JSON.parse(text);
|
|
569
|
+
} catch (e) {
|
|
570
|
+
warn("[llm-meta-widget] page card is not valid JSON; ignoring it:", e.message);
|
|
571
|
+
return empty;
|
|
572
|
+
}
|
|
573
|
+
const ext = doc && doc._meta && doc._meta[STATIC_PRIMITIVES_META] || null;
|
|
574
|
+
if (!ext) {
|
|
575
|
+
warn(`[llm-meta-widget] page card has no _meta["${STATIC_PRIMITIVES_META}"]; ignoring it`);
|
|
576
|
+
return empty;
|
|
577
|
+
}
|
|
578
|
+
const named = (arr, kind) => (Array.isArray(arr) ? arr : []).filter((e) => {
|
|
579
|
+
const ok = e && typeof e.name === "string" && e.name.trim() !== "";
|
|
580
|
+
if (!ok) warn(`[llm-meta-widget] page card: dropping a ${kind} with no name`);
|
|
581
|
+
return ok;
|
|
582
|
+
});
|
|
583
|
+
return {
|
|
584
|
+
tools: named(ext.tools, "tool"),
|
|
585
|
+
prompts: named(ext.prompts, "prompt"),
|
|
586
|
+
// Resources are addressed by uri, not name — resources/read takes the uri.
|
|
587
|
+
resources: (Array.isArray(ext.resources) ? ext.resources : []).filter((e) => {
|
|
588
|
+
const ok = e && typeof e.uri === "string" && e.uri.trim() !== "";
|
|
589
|
+
if (!ok) warn("[llm-meta-widget] page card: dropping a resource with no uri");
|
|
590
|
+
return ok;
|
|
591
|
+
})
|
|
592
|
+
};
|
|
593
|
+
}
|
|
552
594
|
var VOLATILITIES = ["stable", "volatile"];
|
|
553
595
|
var usableSize = (n) => typeof n === "number" && isFinite(n) ? n : null;
|
|
596
|
+
var pickDeclared = (own, fromMeta) => own === void 0 ? fromMeta : own;
|
|
554
597
|
function resourceHints(entry) {
|
|
555
598
|
const meta = entry && entry._meta && entry._meta[STATIC_PRIMITIVES_META] || {};
|
|
556
599
|
return {
|
|
@@ -566,8 +609,13 @@ function resourceHints(entry) {
|
|
|
566
609
|
get sizeBytes() {
|
|
567
610
|
return this.size;
|
|
568
611
|
},
|
|
569
|
-
|
|
570
|
-
|
|
612
|
+
// Same rule as `size`: the entry's own field wins, `_meta` is the fallback.
|
|
613
|
+
// SEP-0000 defines these ON the Resource object; a server puts them in
|
|
614
|
+
// `_meta` only because the extension is not merged yet. A PAGE card's
|
|
615
|
+
// entries already sit inside the extension block, so for them the fields
|
|
616
|
+
// are simply top-level — reading both is what lets one planner serve both.
|
|
617
|
+
volatility: VOLATILITIES.indexOf(pickDeclared(entry && entry.volatility, meta.volatility)) === -1 ? "stable" : pickDeclared(entry && entry.volatility, meta.volatility),
|
|
618
|
+
autoAttach: typeof pickDeclared(entry && entry.autoAttach, meta.autoAttach) === "boolean" ? pickDeclared(entry && entry.autoAttach, meta.autoAttach) : true
|
|
571
619
|
};
|
|
572
620
|
}
|
|
573
621
|
function planResourceAttachment(entry, budgetBytes) {
|
|
@@ -611,6 +659,45 @@ async function readResourceContext({ endpoint, uri, name, read, budgetBytes }) {
|
|
|
611
659
|
return null;
|
|
612
660
|
}
|
|
613
661
|
}
|
|
662
|
+
function planPageResources({ entries, budgetBytes, onSkip }) {
|
|
663
|
+
return (entries || []).map((entry) => {
|
|
664
|
+
const plan = planResourceAttachment(entry, budgetBytes);
|
|
665
|
+
plan.name = entry.name || entry.uri;
|
|
666
|
+
plan.description = entry.description || "";
|
|
667
|
+
if (!plan.fetch && onSkip) onSkip(plan);
|
|
668
|
+
return plan;
|
|
669
|
+
}).filter((plan) => plan.fetch);
|
|
670
|
+
}
|
|
671
|
+
function pageResourceLine(plan, text) {
|
|
672
|
+
return "- " + plan.name + (plan.description ? " (" + plan.description + ")" : "") + ": " + text;
|
|
673
|
+
}
|
|
674
|
+
async function pageResourceLinesForTurn({ plans, resources, budgetBytes, cache = {} }) {
|
|
675
|
+
const lines = [];
|
|
676
|
+
for (const plan of plans || []) {
|
|
677
|
+
let text = plan.volatility === "volatile" ? null : cache[plan.uri];
|
|
678
|
+
if (text === null || text === void 0) {
|
|
679
|
+
text = await readPageResource({ uri: plan.uri, resources, budgetBytes });
|
|
680
|
+
if (plan.volatility !== "volatile" && text !== null) cache[plan.uri] = text;
|
|
681
|
+
}
|
|
682
|
+
if (text !== null) lines.push(pageResourceLine(plan, text));
|
|
683
|
+
}
|
|
684
|
+
return { lines, cache };
|
|
685
|
+
}
|
|
686
|
+
async function readPageResource({ uri, resources, budgetBytes }) {
|
|
687
|
+
const handler = resources && resources[uri];
|
|
688
|
+
if (typeof handler !== "function") {
|
|
689
|
+
console.warn('[llm-meta-widget] the page declares resource "' + uri + '" but no handler answers to that uri');
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
try {
|
|
693
|
+
const value = await handler();
|
|
694
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
695
|
+
return trimResourceText(text === void 0 ? "" : text, budgetBytes);
|
|
696
|
+
} catch (e) {
|
|
697
|
+
console.warn('[llm-meta-widget] reading "' + uri + '" failed:', e && e.message);
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
614
701
|
async function loadHostResource({ endpoint, budgetBytes, list: list2, read, onSkip }) {
|
|
615
702
|
const resources = await list2({ endpoint });
|
|
616
703
|
const candidate = (resources || []).filter((r) => (r.mimeType || "") === "application/json")[0];
|
|
@@ -650,7 +737,20 @@ async function resourceLinesForTurn({ plan, cached, endpoint, read, budgetBytes
|
|
|
650
737
|
}
|
|
651
738
|
return { lines: resourceContextLines(cached), cached };
|
|
652
739
|
}
|
|
740
|
+
var PAGE_URI_PREFIX = "page:";
|
|
653
741
|
var PROMPT_ARG_ALIASES = { dictionaries: "selected_dictionaries" };
|
|
742
|
+
function promptArgState({ entries = [], resources = {}, state = {} } = {}) {
|
|
743
|
+
const merged = Object.assign({}, state);
|
|
744
|
+
for (const entry of entries) {
|
|
745
|
+
const uri = entry && entry.uri;
|
|
746
|
+
if (!uri || uri.indexOf(PAGE_URI_PREFIX) !== 0) continue;
|
|
747
|
+
const name = uri.slice(PAGE_URI_PREFIX.length);
|
|
748
|
+
const reader = resources[uri];
|
|
749
|
+
if (!name || merged[name] || typeof reader !== "function") continue;
|
|
750
|
+
merged[name] = { description: entry.description || "", read: reader };
|
|
751
|
+
}
|
|
752
|
+
return merged;
|
|
753
|
+
}
|
|
654
754
|
function promptArgFromState(argName, state) {
|
|
655
755
|
const entry = state && state[argName] || state && state[PROMPT_ARG_ALIASES[argName]];
|
|
656
756
|
const reader = entry && typeof entry.read === "function" ? entry.read : null;
|
|
@@ -2904,8 +3004,11 @@ var lexer = _Lexer.lex;
|
|
|
2904
3004
|
var DEFAULTS = Object.freeze({
|
|
2905
3005
|
API_KEY_UUID: "ollama-local",
|
|
2906
3006
|
ACTIONS_SCHEMA_ID: "ai-actions",
|
|
3007
|
+
PAGE_CARD_ID: "ai-page",
|
|
2907
3008
|
STATE_GLOBAL: "aiState",
|
|
2908
3009
|
ACTIONS_GLOBAL: "aiActions",
|
|
3010
|
+
PROMPTS_GLOBAL: "aiPrompts",
|
|
3011
|
+
RESOURCES_GLOBAL: "aiResources",
|
|
2909
3012
|
REMOTE_TOOLS_SCHEMA_ID: "remote-mcp-tools",
|
|
2910
3013
|
LLM_PROVIDER: "llm_meta_server",
|
|
2911
3014
|
MAX_ROUNDS: 3
|
|
@@ -2939,9 +3042,12 @@ function readConfig(el) {
|
|
|
2939
3042
|
API_KEY_UUID: str("api-key-uuid", DEFAULTS.API_KEY_UUID),
|
|
2940
3043
|
MODEL: str("model", null),
|
|
2941
3044
|
ACTIONS_SCHEMA_ID: str("actions-schema-id", DEFAULTS.ACTIONS_SCHEMA_ID),
|
|
3045
|
+
PAGE_CARD_ID: str("page-card-id", DEFAULTS.PAGE_CARD_ID),
|
|
2942
3046
|
GREETING: str("greeting", null),
|
|
2943
3047
|
STATE_GLOBAL: str("state-global", DEFAULTS.STATE_GLOBAL),
|
|
2944
3048
|
ACTIONS_GLOBAL: str("actions-global", DEFAULTS.ACTIONS_GLOBAL),
|
|
3049
|
+
PROMPTS_GLOBAL: str("prompts-global", DEFAULTS.PROMPTS_GLOBAL),
|
|
3050
|
+
RESOURCES_GLOBAL: str("resources-global", DEFAULTS.RESOURCES_GLOBAL),
|
|
2945
3051
|
REMOTE_TOOLS_SCHEMA_ID: str("remote-tools-schema-id", DEFAULTS.REMOTE_TOOLS_SCHEMA_ID),
|
|
2946
3052
|
MAX_ROUNDS: int("max-rounds", DEFAULTS.MAX_ROUNDS),
|
|
2947
3053
|
WELL_KNOWN_URLS: wellKnown === null ? null : wellKnown.trim() === "" ? [] : splitList(wellKnown),
|
|
@@ -3658,9 +3764,12 @@ function boot(cfg) {
|
|
|
3658
3764
|
var API_KEY_UUID = cfg.API_KEY_UUID;
|
|
3659
3765
|
var MODEL = cfg.MODEL;
|
|
3660
3766
|
var ACTIONS_SCHEMA_ID = cfg.ACTIONS_SCHEMA_ID;
|
|
3767
|
+
var PAGE_CARD_ID = cfg.PAGE_CARD_ID;
|
|
3661
3768
|
var GREETING = cfg.GREETING;
|
|
3662
3769
|
var STATE_GLOBAL = cfg.STATE_GLOBAL;
|
|
3663
3770
|
var ACTIONS_GLOBAL = cfg.ACTIONS_GLOBAL;
|
|
3771
|
+
var PROMPTS_GLOBAL = cfg.PROMPTS_GLOBAL;
|
|
3772
|
+
var RESOURCES_GLOBAL = cfg.RESOURCES_GLOBAL;
|
|
3664
3773
|
var REMOTE_TOOLS_SCHEMA_ID = cfg.REMOTE_TOOLS_SCHEMA_ID;
|
|
3665
3774
|
var MAX_ROUNDS = cfg.MAX_ROUNDS;
|
|
3666
3775
|
var WELL_KNOWN_URLS = cfg.WELL_KNOWN_URLS;
|
|
@@ -3791,8 +3900,10 @@ function boot(cfg) {
|
|
|
3791
3900
|
model: MODEL
|
|
3792
3901
|
});
|
|
3793
3902
|
}
|
|
3903
|
+
var pageCardEl = document.getElementById(PAGE_CARD_ID);
|
|
3904
|
+
var pageCard = readStaticPrimitivesCard(pageCardEl ? pageCardEl.textContent : null);
|
|
3794
3905
|
var actionsSchemaEl = document.getElementById(ACTIONS_SCHEMA_ID);
|
|
3795
|
-
var localTools = actionsSchemaEl ? JSON.parse(actionsSchemaEl.textContent) : [];
|
|
3906
|
+
var localTools = pageCard.tools.length ? pageCard.tools : actionsSchemaEl ? JSON.parse(actionsSchemaEl.textContent) : [];
|
|
3796
3907
|
var remoteToolsEl = document.getElementById(REMOTE_TOOLS_SCHEMA_ID);
|
|
3797
3908
|
var baselineRemoteTools = remoteToolsEl ? JSON.parse(remoteToolsEl.textContent) : [];
|
|
3798
3909
|
var remoteTools = baselineRemoteTools.slice();
|
|
@@ -4015,11 +4126,22 @@ function boot(cfg) {
|
|
|
4015
4126
|
}
|
|
4016
4127
|
}
|
|
4017
4128
|
var hostWideTools = [];
|
|
4018
|
-
var hostWidePrompts =
|
|
4129
|
+
var hostWidePrompts = pageCard.prompts.map(function(p) {
|
|
4130
|
+
return Object.assign({}, p, { source: "page" });
|
|
4131
|
+
});
|
|
4019
4132
|
var resourceContext = null;
|
|
4020
4133
|
var resourcePlan = null;
|
|
4021
4134
|
var RESOURCE_BUDGET_BYTES = 8e3;
|
|
4135
|
+
var pageResourcePlans = planPageResources({
|
|
4136
|
+
entries: pageCard.resources,
|
|
4137
|
+
budgetBytes: RESOURCE_BUDGET_BYTES,
|
|
4138
|
+
onSkip: function(plan) {
|
|
4139
|
+
console.info("[llm_meta_widget] not attaching " + plan.uri + " (" + plan.reason + ", size=" + plan.size + ")");
|
|
4140
|
+
}
|
|
4141
|
+
});
|
|
4142
|
+
var pageResourceCache = {};
|
|
4022
4143
|
restoreConversation();
|
|
4144
|
+
var promptsEl = root.querySelector(".lmw-prompts");
|
|
4023
4145
|
var wellKnownReady = (async function() {
|
|
4024
4146
|
var urls = WELL_KNOWN_URLS === null ? [window.location.origin + "/.well-known/mcp.json"] : WELL_KNOWN_URLS;
|
|
4025
4147
|
for (var i = 0; i < urls.length; i++) {
|
|
@@ -4053,7 +4175,13 @@ function boot(cfg) {
|
|
|
4053
4175
|
renderPromptButtons();
|
|
4054
4176
|
renderWelcome();
|
|
4055
4177
|
})();
|
|
4056
|
-
|
|
4178
|
+
function argState() {
|
|
4179
|
+
return promptArgState({
|
|
4180
|
+
entries: pageCard.resources,
|
|
4181
|
+
resources: window[RESOURCES_GLOBAL] || {},
|
|
4182
|
+
state: window[STATE_GLOBAL] || {}
|
|
4183
|
+
});
|
|
4184
|
+
}
|
|
4057
4185
|
function renderPromptButtons() {
|
|
4058
4186
|
if (!promptsEl) return;
|
|
4059
4187
|
promptsEl.textContent = "";
|
|
@@ -4105,7 +4233,7 @@ function boot(cfg) {
|
|
|
4105
4233
|
hello.className = "lmw-welcome-hello";
|
|
4106
4234
|
hello.textContent = GREETING || "Hi \u2014 I can work this page for you. Tell me what you need in your own words" + (hostWidePrompts.length ? ", or start with one of these:" : ".");
|
|
4107
4235
|
box.appendChild(hello);
|
|
4108
|
-
var state =
|
|
4236
|
+
var state = argState();
|
|
4109
4237
|
hostWidePrompts.forEach(function(prompt) {
|
|
4110
4238
|
var props = promptButtonProps(prompt);
|
|
4111
4239
|
var card = document.createElement("div");
|
|
@@ -4141,7 +4269,7 @@ function boot(cfg) {
|
|
|
4141
4269
|
historyEl.appendChild(box);
|
|
4142
4270
|
}
|
|
4143
4271
|
async function runPromptTemplate(prompt, button) {
|
|
4144
|
-
var resolved = resolvePromptArguments(prompt,
|
|
4272
|
+
var resolved = resolvePromptArguments(prompt, argState());
|
|
4145
4273
|
var args = resolved.args;
|
|
4146
4274
|
var missing = resolved.missing;
|
|
4147
4275
|
if (missing.length) {
|
|
@@ -4150,7 +4278,7 @@ function boot(cfg) {
|
|
|
4150
4278
|
}
|
|
4151
4279
|
button.disabled = true;
|
|
4152
4280
|
try {
|
|
4153
|
-
var result = await getMcpPrompt({ endpoint: prompt.endpoint, name: prompt.name, args });
|
|
4281
|
+
var result = prompt.source === "page" ? await getPagePrompt({ name: prompt.name, args, prompts: window[PROMPTS_GLOBAL] }) : await getMcpPrompt({ endpoint: prompt.endpoint, name: prompt.name, args });
|
|
4154
4282
|
var text = promptMessagesToText(result);
|
|
4155
4283
|
if (!text) {
|
|
4156
4284
|
appendTurn("error", "The server returned an empty prompt.");
|
|
@@ -4364,7 +4492,14 @@ function boot(cfg) {
|
|
|
4364
4492
|
budgetBytes: RESOURCE_BUDGET_BYTES
|
|
4365
4493
|
});
|
|
4366
4494
|
resourceContext = turn.cached;
|
|
4367
|
-
|
|
4495
|
+
var page = await pageResourceLinesForTurn({
|
|
4496
|
+
plans: pageResourcePlans,
|
|
4497
|
+
resources: window[RESOURCES_GLOBAL],
|
|
4498
|
+
budgetBytes: RESOURCE_BUDGET_BYTES,
|
|
4499
|
+
cache: pageResourceCache
|
|
4500
|
+
});
|
|
4501
|
+
pageResourceCache = page.cache;
|
|
4502
|
+
return turn.lines.concat(page.lines);
|
|
4368
4503
|
}
|
|
4369
4504
|
var currentAbort = null;
|
|
4370
4505
|
clearBtn.addEventListener("click", function() {
|
|
@@ -730,6 +730,28 @@ export async function readMcpResource({ endpoint, uri, signal }) {
|
|
|
730
730
|
// The spec allows `content` to be an object or an array of content blocks;
|
|
731
731
|
// anything non-text is dropped, which is worth knowing — a prompt carrying an
|
|
732
732
|
// image would silently lose it on this path.
|
|
733
|
+
// A prompt the PAGE declared, resolved against its handler map. This is
|
|
734
|
+
// prompts/get for a surface with no transport: the card carries the metadata
|
|
735
|
+
// (name, description, arguments — what prompts/list returns) and the handler
|
|
736
|
+
// returns the filled messages, exactly as a server's prompts/get would.
|
|
737
|
+
//
|
|
738
|
+
// Keyed by `name` because that is what prompts/get takes. Accepts a plain
|
|
739
|
+
// string as well as a messages array: a page prompt is usually one block of
|
|
740
|
+
// text, and making every host write the envelope by hand would be a tax with
|
|
741
|
+
// no benefit — promptMessagesToText flattens both to the same thing.
|
|
742
|
+
export async function getPagePrompt({ name, args, prompts }) {
|
|
743
|
+
const handler = prompts && prompts[name]
|
|
744
|
+
if (typeof handler !== "function") {
|
|
745
|
+
throw new Error('the page declares prompt "' + name + '" but no handler answers to that name')
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const result = await handler(args || {})
|
|
749
|
+
if (typeof result === "string") {
|
|
750
|
+
return { messages: [ { role: "user", content: { type: "text", text: result } } ] }
|
|
751
|
+
}
|
|
752
|
+
return result || { messages: [] }
|
|
753
|
+
}
|
|
754
|
+
|
|
733
755
|
export function promptMessagesToText(result) {
|
|
734
756
|
return (result?.messages || [])
|
|
735
757
|
.map((m) => {
|
|
@@ -840,9 +862,63 @@ function parseSseFrame(raw) {
|
|
|
840
862
|
// the first turn only.
|
|
841
863
|
export const STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives"
|
|
842
864
|
|
|
865
|
+
// ---- page card --------------------------------------------------------
|
|
866
|
+
//
|
|
867
|
+
// The same `io.modelcontextprotocol/static-primitives` envelope a server puts
|
|
868
|
+
// in its Server Card, delivered in the DOCUMENT instead: one <script> of JSON
|
|
869
|
+
// declaring the page's own tools, prompts and resources. SEP-2575 already says
|
|
870
|
+
// the Server Card and server/discover are two deliveries of one content; a page
|
|
871
|
+
// is a third, with no transport at all.
|
|
872
|
+
//
|
|
873
|
+
// Parsing only. What a declaration MEANS is resolved elsewhere: tool handlers
|
|
874
|
+
// live in window.aiActions keyed by `name`, and reads will be keyed by the
|
|
875
|
+
// protocol's own identifier — `name` for prompts, `uri` for resources, because
|
|
876
|
+
// that is what prompts/get and resources/read take.
|
|
877
|
+
//
|
|
878
|
+
// Malformed input must never take the page down: a card is host-authored and a
|
|
879
|
+
// typo in it should cost that one entry, not the assistant. Everything
|
|
880
|
+
// unparseable returns empty arrays and says so on the console, which is how
|
|
881
|
+
// aiState already treats a reader missing its description.
|
|
882
|
+
export function readStaticPrimitivesCard(text, warn = console.warn) {
|
|
883
|
+
const empty = { tools: [], prompts: [], resources: [] }
|
|
884
|
+
if (typeof text !== "string" || text.trim() === "") return empty
|
|
885
|
+
|
|
886
|
+
let doc
|
|
887
|
+
try {
|
|
888
|
+
doc = JSON.parse(text)
|
|
889
|
+
} catch (e) {
|
|
890
|
+
warn("[llm-meta-widget] page card is not valid JSON; ignoring it:", e.message)
|
|
891
|
+
return empty
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const ext = (doc && doc._meta && doc._meta[STATIC_PRIMITIVES_META]) || null
|
|
895
|
+
if (!ext) {
|
|
896
|
+
warn(`[llm-meta-widget] page card has no _meta["${STATIC_PRIMITIVES_META}"]; ignoring it`)
|
|
897
|
+
return empty
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const named = (arr, kind) => (Array.isArray(arr) ? arr : []).filter((e) => {
|
|
901
|
+
const ok = e && typeof e.name === "string" && e.name.trim() !== ""
|
|
902
|
+
if (!ok) warn(`[llm-meta-widget] page card: dropping a ${kind} with no name`)
|
|
903
|
+
return ok
|
|
904
|
+
})
|
|
905
|
+
|
|
906
|
+
return {
|
|
907
|
+
tools: named(ext.tools, "tool"),
|
|
908
|
+
prompts: named(ext.prompts, "prompt"),
|
|
909
|
+
// Resources are addressed by uri, not name — resources/read takes the uri.
|
|
910
|
+
resources: (Array.isArray(ext.resources) ? ext.resources : []).filter((e) => {
|
|
911
|
+
const ok = e && typeof e.uri === "string" && e.uri.trim() !== ""
|
|
912
|
+
if (!ok) warn("[llm-meta-widget] page card: dropping a resource with no uri")
|
|
913
|
+
return ok
|
|
914
|
+
})
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
843
918
|
const VOLATILITIES = [ "stable", "volatile" ]
|
|
844
919
|
|
|
845
920
|
const usableSize = (n) => (typeof n === "number" && isFinite(n) ? n : null)
|
|
921
|
+
const pickDeclared = (own, fromMeta) => (own === undefined ? fromMeta : own)
|
|
846
922
|
|
|
847
923
|
export function resourceHints(entry) {
|
|
848
924
|
const meta = (entry && entry._meta && entry._meta[STATIC_PRIMITIVES_META]) || {}
|
|
@@ -857,8 +933,17 @@ export function resourceHints(entry) {
|
|
|
857
933
|
// it returns is public API. Dropped at 0.9.0 together with the
|
|
858
934
|
// `_meta.sizeBytes` fallback above — one deprecation, one bump.
|
|
859
935
|
get sizeBytes() { return this.size },
|
|
860
|
-
|
|
861
|
-
|
|
936
|
+
// Same rule as `size`: the entry's own field wins, `_meta` is the fallback.
|
|
937
|
+
// SEP-0000 defines these ON the Resource object; a server puts them in
|
|
938
|
+
// `_meta` only because the extension is not merged yet. A PAGE card's
|
|
939
|
+
// entries already sit inside the extension block, so for them the fields
|
|
940
|
+
// are simply top-level — reading both is what lets one planner serve both.
|
|
941
|
+
volatility: VOLATILITIES.indexOf(pickDeclared(entry && entry.volatility, meta.volatility)) === -1
|
|
942
|
+
? "stable"
|
|
943
|
+
: pickDeclared(entry && entry.volatility, meta.volatility),
|
|
944
|
+
autoAttach: typeof pickDeclared(entry && entry.autoAttach, meta.autoAttach) === "boolean"
|
|
945
|
+
? pickDeclared(entry && entry.autoAttach, meta.autoAttach)
|
|
946
|
+
: true
|
|
862
947
|
}
|
|
863
948
|
}
|
|
864
949
|
|
|
@@ -920,6 +1005,65 @@ async function readResourceContext({ endpoint, uri, name, read, budgetBytes }) {
|
|
|
920
1005
|
}
|
|
921
1006
|
}
|
|
922
1007
|
|
|
1008
|
+
// ---- page resources ----------------------------------------------------
|
|
1009
|
+
//
|
|
1010
|
+
// resources/read for a surface with no transport. The card lists them, a
|
|
1011
|
+
// handler map keyed by URI reads them — URI because that is what
|
|
1012
|
+
// resources/read takes, and because the spec defines it as the unique
|
|
1013
|
+
// identifier while `name` is only required, never unique.
|
|
1014
|
+
//
|
|
1015
|
+
// Unlike a server endpoint, a page declares SEVERAL: PubDictionaries has six
|
|
1016
|
+
// readers, each with its own description. So these are planned and rendered as
|
|
1017
|
+
// a list, not as the single reference resource loadHostResource assumes.
|
|
1018
|
+
export function planPageResources({ entries, budgetBytes, onSkip }) {
|
|
1019
|
+
return (entries || []).map((entry) => {
|
|
1020
|
+
const plan = planResourceAttachment(entry, budgetBytes)
|
|
1021
|
+
plan.name = entry.name || entry.uri
|
|
1022
|
+
plan.description = entry.description || ""
|
|
1023
|
+
if (!plan.fetch && onSkip) onSkip(plan)
|
|
1024
|
+
return plan
|
|
1025
|
+
}).filter((plan) => plan.fetch)
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// One compact line per resource — the same shape the page-state annex uses,
|
|
1029
|
+
// NOT the block form a hub resource gets. Six block-form resources would cost
|
|
1030
|
+
// three lines each and repeat a header the model does not need; how to render
|
|
1031
|
+
// attached context is the client's decision, which the spec leaves open.
|
|
1032
|
+
export function pageResourceLine(plan, text) {
|
|
1033
|
+
return "- " + plan.name + (plan.description ? " (" + plan.description + ")" : "") + ": " + text
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
export async function pageResourceLinesForTurn({ plans, resources, budgetBytes, cache = {} }) {
|
|
1037
|
+
const lines = []
|
|
1038
|
+
for (const plan of plans || []) {
|
|
1039
|
+
let text = plan.volatility === "volatile" ? null : cache[plan.uri]
|
|
1040
|
+
if (text === null || text === undefined) {
|
|
1041
|
+
text = await readPageResource({ uri: plan.uri, resources, budgetBytes })
|
|
1042
|
+
if (plan.volatility !== "volatile" && text !== null) cache[plan.uri] = text
|
|
1043
|
+
}
|
|
1044
|
+
if (text !== null) lines.push(pageResourceLine(plan, text))
|
|
1045
|
+
}
|
|
1046
|
+
return { lines, cache }
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
async function readPageResource({ uri, resources, budgetBytes }) {
|
|
1050
|
+
const handler = resources && resources[uri]
|
|
1051
|
+
if (typeof handler !== "function") {
|
|
1052
|
+
console.warn('[llm-meta-widget] the page declares resource "' + uri + '" but no handler answers to that uri')
|
|
1053
|
+
return null
|
|
1054
|
+
}
|
|
1055
|
+
try {
|
|
1056
|
+
const value = await handler()
|
|
1057
|
+
const text = typeof value === "string" ? value : JSON.stringify(value)
|
|
1058
|
+
return trimResourceText(text === undefined ? "" : text, budgetBytes)
|
|
1059
|
+
} catch (e) {
|
|
1060
|
+
// Optional context — one broken reader must not cost the turn, exactly as
|
|
1061
|
+
// a failed hub read does not.
|
|
1062
|
+
console.warn('[llm-meta-widget] reading "' + uri + '" failed:', e && e.message)
|
|
1063
|
+
return null
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
923
1067
|
// The discovery sequence for one endpoint's reference resource, kept here
|
|
924
1068
|
// rather than in the panel so the decision AND the call site are testable
|
|
925
1069
|
// together: a gate nothing consults is exactly the bug this shape prevents.
|
|
@@ -985,8 +1129,35 @@ export async function resourceLinesForTurn({ plan, cached, endpoint, read, budge
|
|
|
985
1129
|
// (window.aiState) by name. `dictionaries` is read from a
|
|
986
1130
|
// `selected_dictionaries` reader: the page selects a LIST, and the prompt
|
|
987
1131
|
// declares the CSV shape the text_annotation tool already takes.
|
|
1132
|
+
// Page resources are addressed `page:<name>`: a custom scheme because the
|
|
1133
|
+
// protocol asks for a URI and `https:` is reserved for what a client can fetch
|
|
1134
|
+
// itself, and with no path because a page has one namespace.
|
|
1135
|
+
const PAGE_URI_PREFIX = "page:"
|
|
1136
|
+
|
|
988
1137
|
const PROMPT_ARG_ALIASES = { dictionaries: "selected_dictionaries" }
|
|
989
1138
|
|
|
1139
|
+
// Prompt arguments are declared by plain name (`text`); page resources are
|
|
1140
|
+
// addressed by URI (`page:text`). So on a page that declares its state as
|
|
1141
|
+
// resources and keeps no `aiState`, the resolver below finds nothing under the
|
|
1142
|
+
// names it looks up and every argument arrives empty — the visitor is asked to
|
|
1143
|
+
// retype what is already on screen. This presents those resources in the shape
|
|
1144
|
+
// the resolver consumes, keyed by the name inside the URI.
|
|
1145
|
+
//
|
|
1146
|
+
// An explicit `aiState` entry wins: a page running both channels has said what
|
|
1147
|
+
// it wants under that name.
|
|
1148
|
+
export function promptArgState({ entries = [], resources = {}, state = {} } = {}) {
|
|
1149
|
+
const merged = Object.assign({}, state)
|
|
1150
|
+
for (const entry of entries) {
|
|
1151
|
+
const uri = entry && entry.uri
|
|
1152
|
+
if (!uri || uri.indexOf(PAGE_URI_PREFIX) !== 0) continue
|
|
1153
|
+
const name = uri.slice(PAGE_URI_PREFIX.length)
|
|
1154
|
+
const reader = resources[uri]
|
|
1155
|
+
if (!name || merged[name] || typeof reader !== "function") continue
|
|
1156
|
+
merged[name] = { description: entry.description || "", read: reader }
|
|
1157
|
+
}
|
|
1158
|
+
return merged
|
|
1159
|
+
}
|
|
1160
|
+
|
|
990
1161
|
export function promptArgFromState(argName, state) {
|
|
991
1162
|
// Same object shape the system-prompt annex consumes: { description, read }.
|
|
992
1163
|
// The bare-function form was removed in 0.8.0, so an entry without `read` is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aibranch/llm-meta-widget",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"//version": "Must stay in step with lib/llm_meta_widget/version.rb. Two independently versioned copies of the same client is the drift docs/custom-element-distribution.md warns about: bump both, release both.",
|
|
5
5
|
"description": "Embeddable browser chat widget: a custom element that discovers tools at page, origin and hub scope and dispatches each tool call to the right one. Two lines of HTML on any host, Rails or not.",
|
|
6
6
|
"keywords": [
|