@aibranch/llm-meta-widget 0.10.0 → 0.11.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/README.md
CHANGED
|
@@ -12,13 +12,13 @@ npm for any host, and in a Rails gem whose helper serves it for you.
|
|
|
12
12
|
|
|
13
13
|
**No** Devise, DB migrations, ChatManager, or PromptNavigator. The gem adds only `rails >= 8.0` as a runtime dep — so hosts that haven't bumped to 8.1 can adopt it without a Rails upgrade — and hosts that do not use Rails do not need the gem.
|
|
14
14
|
|
|
15
|
-
##
|
|
15
|
+
## Requirement
|
|
16
16
|
|
|
17
|
-
The widget is a browser front end: it needs something to answer the chat.
|
|
18
|
-
|
|
19
|
-
holds provider credentials and streams responses back
|
|
20
|
-
which the widget talks to directly with no server component of ours
|
|
21
|
-
between.
|
|
17
|
+
The widget is a browser front end: it needs something to answer the chat. One
|
|
18
|
+
option is **[llm_meta_server](https://github.com/pubannotation)**, a hub that
|
|
19
|
+
holds provider credentials and streams responses back. The other is **an
|
|
20
|
+
Ollama**, which the widget talks to directly, with no server component of ours
|
|
21
|
+
in between.
|
|
22
22
|
|
|
23
23
|
Those are separate from where tools come from. A hub also registers MCP tools
|
|
24
24
|
(Class 1 below), and you can point at one for tools while a local Ollama
|
|
@@ -32,29 +32,39 @@ step, no Node at runtime.
|
|
|
32
32
|
|---|---|---|
|
|
33
33
|
| A browser with custom elements + ES modules | any current | always |
|
|
34
34
|
| An llm_meta_server **or** an Ollama | any | always — something has to answer the chat |
|
|
35
|
-
| Ruby | >= 3.2 | only if you install the gem |
|
|
36
|
-
| Rails | >= 8.0 (8.1 not required) | only if you install the gem |
|
|
37
35
|
|
|
38
|
-
|
|
36
|
+
Nothing is required of your server: the widget runs in the browser, and your
|
|
37
|
+
page only has to send the file and write one tag. The Rails gem is one
|
|
38
|
+
convenient way to do that, not a requirement — it needs Ruby >= 3.2 and
|
|
39
|
+
Rails >= 8.0, and only if you choose it.
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
that section.
|
|
41
|
+
## Installation — choose one
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
You need only one. Find the row whose condition matches your app, then read only
|
|
44
|
+
that option's section.
|
|
45
|
+
|
|
46
|
+
| Option | Use it when | What you do |
|
|
44
47
|
|---|---|---|
|
|
45
|
-
| **
|
|
46
|
-
| **
|
|
48
|
+
| **gem** | your app is Rails | add one line to your `Gemfile`, then make one helper call in a view. The helper writes the `<llm-meta-widget>` tag for you, so you never write that tag yourself |
|
|
49
|
+
| **npm** | your app already builds JavaScript — Vite, webpack, esbuild, Next.js… | install the package, import it once at your entry point, and write the `<llm-meta-widget>` tag yourself |
|
|
50
|
+
| **CDN** | neither of the above applies — Python, Go, PHP, plain HTML… | add one `<script>` tag and write the `<llm-meta-widget>` tag yourself. No install, no build step |
|
|
51
|
+
|
|
52
|
+
All three load the same file and run the same widget. The first two pin the
|
|
53
|
+
version for you — Bundler for the gem, your lockfile for npm — which is why they
|
|
54
|
+
come first here. The CDN pins only a range written into a URL, so the last row
|
|
55
|
+
trades that check for needing nothing installed.
|
|
47
56
|
|
|
48
|
-
|
|
57
|
+
## Option 1. gem (when your app is Rails)
|
|
49
58
|
|
|
50
|
-
|
|
59
|
+
**Not using Rails?** Skip to Option 2 if your app builds JavaScript, or to
|
|
60
|
+
Option 3 if it does not.
|
|
51
61
|
|
|
52
62
|
```ruby
|
|
53
63
|
# Gemfile
|
|
54
|
-
gem "llm_meta_widget", "~> 0.
|
|
64
|
+
gem "llm_meta_widget", "~> 0.11.0"
|
|
55
65
|
```
|
|
56
66
|
|
|
57
|
-
```
|
|
67
|
+
```bash
|
|
58
68
|
bundle install
|
|
59
69
|
```
|
|
60
70
|
|
|
@@ -83,7 +93,7 @@ simpler way to deliver and configure the widget:
|
|
|
83
93
|
- **The defaults are set for you**, so your view names only the options you
|
|
84
94
|
want to change.
|
|
85
95
|
|
|
86
|
-
|
|
96
|
+
### A minimal working example for Rails apps
|
|
87
97
|
|
|
88
98
|
Put this on any view — a fresh `pages/demo.html.erb` is fine:
|
|
89
99
|
|
|
@@ -91,75 +101,19 @@ Put this on any view — a fresh `pages/demo.html.erb` is fine:
|
|
|
91
101
|
<h1>Demo</h1>
|
|
92
102
|
|
|
93
103
|
<%= llm_meta_widget(llm_url: "https://your-meta-server.example",
|
|
94
|
-
model: "
|
|
104
|
+
model: "gpt-oss-120b",
|
|
95
105
|
greeting: "Hi — ask me anything about this page.") %>
|
|
96
106
|
```
|
|
97
107
|
|
|
98
108
|
Start the app, open that page, and click the round toggle at the bottom right.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
streams in.
|
|
102
|
-
|
|
103
|
-
If the panel opens but nothing comes back, it is almost always CORS. The hub
|
|
104
|
-
must list your app's origin in its `CORS_ORIGINS` environment variable for
|
|
105
|
-
the `/api/*` resource, because the widget calls the hub **from the visitor's
|
|
106
|
-
browser**, not from your server. Open the browser console: a blocked request
|
|
107
|
-
says so explicitly.
|
|
108
|
-
|
|
109
|
-
Two more things worth knowing before you go further:
|
|
110
|
-
|
|
111
|
-
- `llm_url` must be reachable from your visitors' browsers, not just from
|
|
112
|
-
your server. `localhost` works only while you are the visitor — which is
|
|
113
|
-
fine when each visitor runs their own Ollama.
|
|
114
|
-
- The model name is the hub's name for it (`GET /api/llms` lists them), not
|
|
115
|
-
the provider's.
|
|
109
|
+
What success looks like, and what to check when nothing comes back, is in
|
|
110
|
+
"When it does not answer" below — the causes are the same for every route.
|
|
116
111
|
|
|
117
112
|
Once that works, the widget can talk, but it cannot *do* anything yet. To let the
|
|
118
113
|
LLM act on your page or call your own services, declare tools using any of the
|
|
119
114
|
**three tool classes** below.
|
|
120
115
|
|
|
121
|
-
##
|
|
122
|
-
|
|
123
|
-
**If your app is Rails, you can skip this section.** The gem already does
|
|
124
|
-
everything described here: its helper writes this tag for you and its engine
|
|
125
|
-
sends this file. Read on if your app is not Rails — or if you use Rails but
|
|
126
|
-
prefer the CDN to the gem, which the `element_path:` option allows.
|
|
127
|
-
|
|
128
|
-
The widget is a custom element in a single self-contained ES module, so a host
|
|
129
|
-
that is not Rails needs no gem, no template engine and no asset pipeline — two
|
|
130
|
-
lines of HTML:
|
|
131
|
-
|
|
132
|
-
```html
|
|
133
|
-
<script type="module" src="https://cdn.jsdelivr.net/npm/@aibranch/llm-meta-widget@0.8"></script>
|
|
134
|
-
<llm-meta-widget llm-url="https://your-meta-server.example"
|
|
135
|
-
model="qwen3-6-35b-fast"
|
|
136
|
-
greeting="Hi — ask me anything about this page."></llm-meta-widget>
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
This is the same configuration as the Rails example above, written in HTML
|
|
140
|
-
instead of ERB. Both produce the same widget, so the advice in that section
|
|
141
|
-
— CORS, a reachable `llm_url`, the hub's name for the model — applies here
|
|
142
|
-
too.
|
|
143
|
-
|
|
144
|
-
**`@0.8` is a version range, and it stops at 0.8.x on purpose.** You receive bug
|
|
145
|
-
fixes without editing your page, and a release that breaks your page cannot
|
|
146
|
-
arrive on its own.
|
|
147
|
-
|
|
148
|
-
Moving to 0.9 is therefore a change you make yourself. Read
|
|
149
|
-
[CHANGELOG.md](CHANGELOG.md) before you do, because this is where what your page
|
|
150
|
-
must provide can change. For example, 0.8.0 changed the shape of
|
|
151
|
-
`window.aiState`. A page that used the new shape while still loading `@0.7`
|
|
152
|
-
would have shown an error string in place of every value, with no other warning.
|
|
153
|
-
|
|
154
|
-
A Rails app is protected from that mismatch, because the `Gemfile` will not
|
|
155
|
-
resolve a widget version the page cannot use. A page that loads the widget from
|
|
156
|
-
the CDN has no such check, so the version you write in that URL is the only
|
|
157
|
-
protection you have.
|
|
158
|
-
|
|
159
|
-
Nothing else is needed: the stylesheets and the markdown renderer are bundled
|
|
160
|
-
in, and the element injects its own styles. The host serves no CSS and no JS.
|
|
161
|
-
|
|
162
|
-
### If you have a JS build pipeline, install it instead
|
|
116
|
+
## Option 2. npm (if you bundle your JavaScript)
|
|
163
117
|
|
|
164
118
|
Vite, webpack, esbuild, Next.js, SvelteKit, Astro — if your app already builds
|
|
165
119
|
JavaScript, take the widget as a dependency rather than a script tag:
|
|
@@ -174,8 +128,8 @@ npm install @aibranch/llm-meta-widget
|
|
|
174
128
|
import "@aibranch/llm-meta-widget";
|
|
175
129
|
```
|
|
176
130
|
|
|
177
|
-
Then write the `<llm-meta-widget>` tag
|
|
178
|
-
|
|
131
|
+
Then write the `<llm-meta-widget>` tag yourself — see "Writing the element
|
|
132
|
+
yourself" below for the tag and its attributes.
|
|
179
133
|
|
|
180
134
|
**Why do this, when the script tag is only one line?** Because your build then
|
|
181
135
|
checks the version for you. The version is pinned in your lockfile and installed by `npm ci`
|
|
@@ -185,15 +139,57 @@ that nothing checks. That matters most at a breaking release: 0.8.0 changed the
|
|
|
185
139
|
shape of `window.aiState`, and a lockfile makes the upgrade a deliberate, visible
|
|
186
140
|
step instead of a URL someone edits.
|
|
187
141
|
|
|
188
|
-
The only cost is that this path needs a build pipeline.
|
|
189
|
-
|
|
142
|
+
The only cost is that this path needs a build pipeline. If your app does not
|
|
143
|
+
build JavaScript, the CDN route below needs none.
|
|
190
144
|
|
|
191
|
-
|
|
145
|
+
## Option 3. CDN
|
|
192
146
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
147
|
+
This is the shortest route and it works anywhere — Python, Go, PHP, plain
|
|
148
|
+
HTML, or Rails if you would rather not add the gem (the `element_path:` option
|
|
149
|
+
allows that). It needs no install, no build step and no Node at runtime.
|
|
150
|
+
|
|
151
|
+
The widget is a custom element in a single self-contained ES module, so the
|
|
152
|
+
whole integration is two lines of HTML:
|
|
153
|
+
|
|
154
|
+
```html
|
|
155
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/@aibranch/llm-meta-widget@0.11"></script>
|
|
156
|
+
<llm-meta-widget llm-url="https://your-meta-server.example"
|
|
157
|
+
model="gpt-oss-120b"
|
|
158
|
+
greeting="Hi — ask me anything about this page."></llm-meta-widget>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**`@0.11` is a version range, and it stops at 0.11.x on purpose.** You receive
|
|
162
|
+
bug fixes without editing your page, and a release that breaks your page cannot
|
|
163
|
+
arrive on its own.
|
|
164
|
+
|
|
165
|
+
Moving to 0.12 is therefore a change you make yourself. Read
|
|
166
|
+
[CHANGELOG.md](CHANGELOG.md) before you do, because this is where what your page
|
|
167
|
+
must provide can change. For example, 0.10.0 changed how the widget finds a page
|
|
168
|
+
card: by media type rather than by element id. A page that declared its card the
|
|
169
|
+
new way while still loading `@0.9` lost every tool, prompt and resource it had
|
|
170
|
+
declared. Nothing failed, anywhere. The CHANGELOG entry for 0.10.0 describes it.
|
|
171
|
+
|
|
172
|
+
A Rails app is protected from that mismatch, because the `Gemfile` will not
|
|
173
|
+
resolve a widget version the page cannot use. A page that loads the widget from
|
|
174
|
+
the CDN has no such check, so the version you write in that URL is the only
|
|
175
|
+
protection you have.
|
|
176
|
+
|
|
177
|
+
Nothing else is needed: the stylesheets and the markdown renderer are bundled
|
|
178
|
+
in, and the element injects its own styles. The host serves no CSS and no JS.
|
|
179
|
+
|
|
180
|
+
## Writing the element yourself
|
|
181
|
+
|
|
182
|
+
Both the npm and CDN routes end the same way: your page writes one
|
|
183
|
+
`<llm-meta-widget>` tag. This section is the reference for that tag, whichever
|
|
184
|
+
route brought you here. (Rails users can skip it — the helper writes the tag.)
|
|
185
|
+
|
|
186
|
+
The tag needs one file: the bundled custom element. Four ways to get it, in
|
|
187
|
+
descending order of convenience:
|
|
188
|
+
|
|
189
|
+
- **the CDN** — `@aibranch/llm-meta-widget` on npm, no path needed because the
|
|
190
|
+
package's `main` is the bundle;
|
|
191
|
+
- **npm install plus your own bundler** — the same package, resolved through
|
|
192
|
+
`exports` and pinned by your lockfile;
|
|
197
193
|
- **the gem**, which serves the identical file at
|
|
198
194
|
`/llm_meta_widget_assets/llm-meta-widget.js` for Rails hosts, and is what the
|
|
199
195
|
`llm_meta_widget` helper points at;
|
|
@@ -202,9 +198,8 @@ Four ways to get that one file, in descending order of convenience:
|
|
|
202
198
|
|
|
203
199
|
Going without the gem removes the Rails parts: the helper and the partial. It
|
|
204
200
|
does not change what your page itself must provide, because those parts belong to
|
|
205
|
-
the page, not to Rails. You declare the
|
|
206
|
-
|
|
207
|
-
below.
|
|
201
|
+
the page, not to Rails. You declare the page card and its handler maps exactly as
|
|
202
|
+
a Rails page does — see the three tool classes below.
|
|
208
203
|
|
|
209
204
|
**Attributes** match the helper's keyword options one to one. Three of them are
|
|
210
205
|
easy to get wrong:
|
|
@@ -235,21 +230,110 @@ result is committed and shipped in the gem, because a gem cannot run a build
|
|
|
235
230
|
step on install — and `npm test` fails if the committed bundle has drifted from
|
|
236
231
|
its sources.
|
|
237
232
|
|
|
233
|
+
## When it does not answer
|
|
234
|
+
|
|
235
|
+
**Success looks like this:** the panel opens showing your greeting, you type
|
|
236
|
+
"hello", the role label turns into a spinning gear reading "Working…", and the
|
|
237
|
+
reply streams in.
|
|
238
|
+
|
|
239
|
+
If the panel opens but nothing comes back, it is almost always CORS. The hub
|
|
240
|
+
must list your app's origin in its `CORS_ORIGINS` environment variable for
|
|
241
|
+
the `/api/*` resource, because the widget calls the hub **from the visitor's
|
|
242
|
+
browser**, not from your server. Open the browser console: a blocked request
|
|
243
|
+
says so explicitly.
|
|
244
|
+
|
|
245
|
+
Two more things worth knowing before you go further:
|
|
246
|
+
|
|
247
|
+
- `llm_url` must be reachable from your visitors' browsers, not just from
|
|
248
|
+
your server. `localhost` works only while you are the visitor — which is
|
|
249
|
+
fine when each visitor runs their own Ollama.
|
|
250
|
+
- The model name is the hub's name for it (`GET /api/llms` lists them), not
|
|
251
|
+
the provider's.
|
|
252
|
+
|
|
238
253
|
## Three tool classes
|
|
239
254
|
|
|
240
255
|
The widget classifies every tool_call the LLM emits by name and dispatches to one of three execution paths. Each class has a different declaration, different visibility to the LLM, and different execution semantics.
|
|
241
256
|
|
|
242
|
-
### Class 3 — Page-embedded
|
|
257
|
+
### Class 3 — Page-embedded primitives (the page card)
|
|
243
258
|
|
|
244
259
|
Declared inline on the same view as the widget. Runs as JavaScript in the browser — no HTTP hop, no server involvement. Best for actions that mutate the page's own state ("click this button", "add to selection", "scroll to X").
|
|
245
260
|
|
|
246
|
-
|
|
261
|
+
The same block declares the page's other primitives too: resources, which are what the model may read from the page, and prompts, which are templates the visitor can run. So everything the page offers is in one place, and each kind has one handler map.
|
|
262
|
+
|
|
263
|
+
**Declaration — the page card.** The page declares what it offers in one block
|
|
264
|
+
of JSON, found by its media type, and implements it in one handler map per
|
|
265
|
+
primitive type:
|
|
266
|
+
|
|
267
|
+
```html
|
|
268
|
+
<script type="application/mcp-static-primitives+json">
|
|
269
|
+
{
|
|
270
|
+
"name": "org.example/your-page",
|
|
271
|
+
"remotes": [ { "type": "page-global", "global": "mcp" } ],
|
|
272
|
+
"_meta": { "io.modelcontextprotocol/static-primitives": {
|
|
273
|
+
"tools": [
|
|
274
|
+
{ "name": "add_dictionaries",
|
|
275
|
+
"description": "Add the named dictionaries to the current selection.",
|
|
276
|
+
"inputSchema": {
|
|
277
|
+
"type": "object",
|
|
278
|
+
"properties": { "names": { "type": "array", "items": { "type": "string" } } },
|
|
279
|
+
"required": [ "names" ] } }
|
|
280
|
+
],
|
|
281
|
+
"resources": [
|
|
282
|
+
{ "uri": "page:selected_dictionaries",
|
|
283
|
+
"name": "Selected dictionaries",
|
|
284
|
+
"description": "the dictionaries the user has chosen for annotation",
|
|
285
|
+
"volatility": "volatile" }
|
|
286
|
+
]
|
|
287
|
+
} }
|
|
288
|
+
}
|
|
289
|
+
</script>
|
|
290
|
+
|
|
291
|
+
<script>
|
|
292
|
+
// The card named this global; each map inside it is keyed by the identifier
|
|
293
|
+
// its own MCP operation takes — a tool by name, a resource by uri.
|
|
294
|
+
window.mcp = {
|
|
295
|
+
tools: { add_dictionaries: function (args) { /* mutate the page */ } },
|
|
296
|
+
resources: { "page:selected_dictionaries": function () { return getSelected(); } }
|
|
297
|
+
};
|
|
298
|
+
</script>
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
A tool's schema may be spelled `inputSchema`, as MCP spells it, or
|
|
302
|
+
`input_schema`. Both are accepted.
|
|
303
|
+
|
|
304
|
+
`remotes` is how a consumer other than this widget finds your handlers, so the
|
|
305
|
+
global is declared rather than assumed. Leave it out and the widget reads
|
|
306
|
+
`window.aiActions`, `window.aiPrompts` and `window.aiResources` instead — see
|
|
307
|
+
"Say where your handlers live".
|
|
308
|
+
|
|
309
|
+
A page may declare `prompts` in the same block. See [The page card](#the-page-card)
|
|
310
|
+
for prompts, for the size and volatility hints on a resource, and for what
|
|
311
|
+
happens when a document holds several blocks.
|
|
312
|
+
|
|
313
|
+
**What the LLM sees.** Declared tools are passed to the meta-server as
|
|
314
|
+
`local_tools` for every turn. Every declared resource is read and rendered into
|
|
315
|
+
the `Current page state:` block of the system prompt, one line per resource,
|
|
316
|
+
with its description beside its value:
|
|
317
|
+
|
|
318
|
+
```
|
|
319
|
+
Current page state:
|
|
320
|
+
- Selected dictionaries (the dictionaries the user has chosen for annotation): ["uberon"]
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
So the LLM can answer from page state directly instead of tool-calling for a
|
|
324
|
+
lookup, and it knows what each value *means* rather than guessing from a key.
|
|
325
|
+
A resource marked `"volatility": "volatile"` is re-read every turn, which is
|
|
326
|
+
what a form field needs: a value read once at boot is already stale by the time
|
|
327
|
+
anyone sends a message.
|
|
328
|
+
|
|
329
|
+
**The older channels — `#ai-actions` and `window.aiState`.** These came before
|
|
330
|
+
the page card and still work. A page that declares no card, or a card with no
|
|
331
|
+
`tools`, falls back to them. Use them if you have them already; use the card for
|
|
332
|
+
a new page, because it is the only channel that can also declare prompts and
|
|
333
|
+
resources.
|
|
247
334
|
|
|
248
335
|
```html
|
|
249
|
-
<!--
|
|
250
|
-
type, properties, required, description, items, enum. No $schema is
|
|
251
|
-
declared and nothing here validates one: the object is forwarded to
|
|
252
|
-
the provider as-is, and each provider accepts its own subset. -->
|
|
336
|
+
<!-- Tool schemas. Note input_schema, not inputSchema, in this channel. -->
|
|
253
337
|
<script type="application/json" id="ai-actions">
|
|
254
338
|
[
|
|
255
339
|
{ "name": "add_dictionaries",
|
|
@@ -257,28 +341,22 @@ Declared inline on the same view as the widget. Runs as JavaScript in the browse
|
|
|
257
341
|
"input_schema": {
|
|
258
342
|
"type": "object",
|
|
259
343
|
"properties": { "names": { "type": "array", "items": { "type": "string" } } },
|
|
260
|
-
"required": ["names"]
|
|
344
|
+
"required": [ "names" ]
|
|
261
345
|
} }
|
|
262
346
|
]
|
|
263
347
|
</script>
|
|
264
348
|
|
|
265
|
-
<!--
|
|
266
|
-
<!-- so the LLM can answer from page state without a tool_call. -->
|
|
267
|
-
<!-- Each reader declares what it returns, like every other channel. -->
|
|
349
|
+
<!-- State readers, folded into the system prompt every turn. -->
|
|
268
350
|
<script>
|
|
269
351
|
window.aiState = {
|
|
270
352
|
text: {
|
|
271
353
|
description: "the text the user wants to annotate",
|
|
272
354
|
read: function() { return $("#text").val() || ""; }
|
|
273
|
-
},
|
|
274
|
-
selected_dictionaries: {
|
|
275
|
-
description: "the dictionaries the user has chosen for annotation",
|
|
276
|
-
read: function() { return getSelected(); }
|
|
277
355
|
}
|
|
278
356
|
};
|
|
279
357
|
</script>
|
|
280
358
|
|
|
281
|
-
<!--
|
|
359
|
+
<!-- Action implementations. The card uses this same map. -->
|
|
282
360
|
<script>
|
|
283
361
|
window.aiActions = {
|
|
284
362
|
add_dictionaries: function(args) { /* mutate the page here */ }
|
|
@@ -286,26 +364,28 @@ Declared inline on the same view as the widget. Runs as JavaScript in the browse
|
|
|
286
364
|
</script>
|
|
287
365
|
```
|
|
288
366
|
|
|
289
|
-
|
|
367
|
+
Where both a card and these blocks exist, the card's `tools` replace the
|
|
368
|
+
`#ai-actions` block. They are not merged. Two sources for one list would leave a
|
|
369
|
+
reader guessing which one the page meant. For the same reason, an explicit
|
|
370
|
+
`window.aiState` entry is used in place of a page resource of the same name.
|
|
290
371
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
So the LLM can answer from state directly instead of tool-calling for lookups, and knows what each value *means* rather than guessing from the key.
|
|
298
|
-
|
|
299
|
-
**Breaking change in 0.8.0.** A reader must be `{ description, read }`. The bare-function form — `text: function() {…}` — is removed, with no fallback. A key that still uses it is reported to the console by name, with the shape it should have, and skipped; the page's other readers keep working. Descriptions are not decoration: every other channel the widget declares to the model already carries one (Class 3 tool schemas, Class 2 `.well-known` tools, Class 1 hub tools, resources), and state readers were the exception.
|
|
372
|
+
**Breaking change in 0.8.0.** An `aiState` reader must be
|
|
373
|
+
`{ description, read }`. The bare-function form — `text: function() {…}` — is
|
|
374
|
+
removed, with no fallback. A key that still uses it is reported to the console by
|
|
375
|
+
name, with the shape it should have, and skipped; the page's other readers keep
|
|
376
|
+
working. Descriptions are not decoration: every other channel the widget
|
|
377
|
+
declares to the model already carries one, and state readers were the exception.
|
|
300
378
|
|
|
301
379
|
**When the tool_call fires.** During the turn, as soon as the LLM emits the
|
|
302
|
-
call. Since 0.4.0 the action's
|
|
303
|
-
|
|
304
|
-
the
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
380
|
+
call. Since 0.4.0 the action's result is given back to the model and the turn
|
|
381
|
+
continues. The result is `{ok: true, applied: "<name>"}`, or the error message
|
|
382
|
+
if the action raised one.
|
|
383
|
+
|
|
384
|
+
Before 0.4.0 the widget did not wait for that result. A turn whose only calls
|
|
385
|
+
were page actions therefore ended at the write, and a task of the form *change
|
|
386
|
+
the page, then do something with it* stopped half way. The model can now also
|
|
387
|
+
see that an action failed and correct itself, instead of the failure being
|
|
388
|
+
visible only to the visitor.
|
|
309
389
|
|
|
310
390
|
Budget for it: every page action now costs a round, so a flow that writes twice
|
|
311
391
|
and then calls a tool needs roughly five or six. See `max_rounds:` below.
|
|
@@ -337,11 +417,11 @@ Declared once per host origin via a well-known manifest. Widget fetches the mani
|
|
|
337
417
|
|
|
338
418
|
**Auto-discovery.** By default the widget fetches `<same-origin>/.well-known/mcp.json` at boot. To override, pass `well_known_urls:` to the helper: an explicit array (e.g. `["https://other.example/.well-known/mcp.json"]`) or `[]` to disable entirely.
|
|
339
419
|
|
|
340
|
-
**What the LLM sees.** Every tool listed in every discovered manifest is
|
|
420
|
+
**What the LLM sees.** Every tool listed in every discovered manifest is offered to the model. Names must be unique across manifests. If two classes declare the same name, the widget uses Class 3 first, then Class 2, then Class 1.
|
|
341
421
|
|
|
342
|
-
**When the tool_call fires.**
|
|
422
|
+
**When the tool_call fires.** During the turn. The widget POSTs `{ jsonrpc, method: "tools/call", params: { name, arguments } }` to the tool's URL and waits for the JSON-RPC response. It then gives the result to the model as tool output for the next round. This is the standard MCP loop.
|
|
343
423
|
|
|
344
|
-
**Error path.** HTTP 4xx
|
|
424
|
+
**Error path.** An HTTP 4xx or 5xx, a network failure, or an MCP JSON-RPC error all count as a failed call. The visitor sees a red chip in the message footer. The model receives the error as the tool's return value, so it can explain what went wrong instead of pretending the call worked.
|
|
345
425
|
|
|
346
426
|
### Class 1 — Hub-registered MCP (via the meta-server)
|
|
347
427
|
|
|
@@ -351,15 +431,15 @@ Declared out-of-band on the meta-server (via the hub's admin UI or `/user/:id/mc
|
|
|
351
431
|
|
|
352
432
|
**What the LLM sees.** Only tools the visitor has **enabled via the tool picker** (see "Level-1 pickers" below) — nothing is auto-selected, the visitor opts in per session. A page can set the starting selection instead, with `remote_tools_schema_id:`: those tools arrive already ticked, and the visitor can still untick them.
|
|
353
433
|
|
|
354
|
-
**When the tool_call fires.**
|
|
434
|
+
**When the tool_call fires.** During the turn. The widget POSTs to the hub's `/api/llm_api_keys/:uuid/models/:name/single_llm_calls` endpoint with `tool_ids: [...]`. The hub calls each MCP server and streams the results back over SSE.
|
|
355
435
|
|
|
356
|
-
**Error path.**
|
|
436
|
+
**Error path.** Errors from the hub arrive as SSE `event: error` frames, each with a code such as `mcp_unavailable`, `timeout` or `rate_limit`. They cover a rate limit, a timeout, an MCP server that is unavailable, and a failure further upstream. The widget shows each one in the message bubble, with a prefix for its code.
|
|
357
437
|
|
|
358
438
|
## Level-1 pickers
|
|
359
439
|
|
|
360
440
|
By default the widget renders two pickers below the input textarea:
|
|
361
441
|
|
|
362
|
-
- **Model dropdown** — populated from the hub's `GET /api/llms
|
|
442
|
+
- **Model dropdown** — populated from the hub's `GET /api/llms`. The anonymous path returns every model the hub serves free, which may include hosted ones, not only a local Ollama.
|
|
363
443
|
- **Tool picker** — populated from the hub's `GET /api/mcp_servers` (anon path returns `public_to_anonymous: true` servers). Two-level UX: server bulk-toggle + individual tool checkboxes on expand.
|
|
364
444
|
|
|
365
445
|
Both pickers require **CORS**, the same way the chat itself does: the hub must list your page's origin in its `CORS_ORIGINS` environment variable for the `/api/*` resource. Without that the fetches are blocked silently and both pickers simply stay empty.
|
|
@@ -370,10 +450,10 @@ To adjust picker behavior at the helper call site:
|
|
|
370
450
|
<%= llm_meta_widget(
|
|
371
451
|
llm_url: "https://your-meta-server.example",
|
|
372
452
|
tool_hub_url: "https://your-meta-server.example",
|
|
373
|
-
model: "
|
|
453
|
+
model: "gpt-oss-120b", # initial selection
|
|
374
454
|
enable_model_picker: true, # false → omit picker, use fixed `model:`
|
|
375
455
|
enable_tool_picker: true, # false → omit picker, no Class-1 tools
|
|
376
|
-
models: nil, # nil = all anon models; ["
|
|
456
|
+
models: nil, # nil = all anon models; ["gpt-oss-120b", …] = allowlist
|
|
377
457
|
hub_tools: nil # nil = all anon-public MCPs; ["togomcp", …] = allowlist by server name
|
|
378
458
|
) %>
|
|
379
459
|
```
|
|
@@ -401,11 +481,11 @@ translate directly.
|
|
|
401
481
|
| `model:` | required | Initial model (also the fallback when picker is disabled) |
|
|
402
482
|
| `api_key_uuid:` | `"ollama-local"` | Hub API-key uuid to invoke |
|
|
403
483
|
| `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 |
|
|
404
|
-
| `actions_schema_id:` | `"ai-actions"` | DOM id of the Class-3 schema block |
|
|
405
|
-
| `page_card_id:` | `"ai-page"` |
|
|
484
|
+
| `actions_schema_id:` | `"ai-actions"` | DOM id of the older Class-3 schema block, used when the page declares no card tools |
|
|
485
|
+
| `page_card_id:` | `"ai-page"` | **Fallback only.** A card is normally found by its media type (see "The page card"); this id is read when the page has no block with that type. Its tools replace the `actions_schema_id` block |
|
|
406
486
|
| `prompts_global:` | `"aiPrompts"` | Global object holding page prompt handlers, keyed by prompt name |
|
|
407
487
|
| `resources_global:` | `"aiResources"` | Global object holding page resource readers, keyed by resource URI |
|
|
408
|
-
| `state_global:` | `"aiState"` | Global
|
|
488
|
+
| `state_global:` | `"aiState"` | Global holding the older Class-3 state readers, each `{ description, read }` (see the breaking change in 0.8.0). A page card declares state as resources instead |
|
|
409
489
|
| `actions_global:` | `"aiActions"` | Global window object holding Class-3 implementations |
|
|
410
490
|
| `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 |
|
|
411
491
|
| `well_known_urls:` | `nil` | `nil` = auto-discover same-origin; explicit array = fetch those; `[]` = disable |
|
|
@@ -419,34 +499,49 @@ translate directly.
|
|
|
419
499
|
## The page card
|
|
420
500
|
|
|
421
501
|
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.
|
|
423
|
-
an MCP server
|
|
424
|
-
|
|
502
|
+
using a separate channel for each kind of thing. **That block is a Server Card** —
|
|
503
|
+
the same document an MCP server publishes — whose transport is the page itself.
|
|
504
|
+
So the page and the server describe themselves in one format, and one parser
|
|
505
|
+
reads both.
|
|
506
|
+
|
|
507
|
+
Since 0.10.0 the block is found by its media type. Since 0.11.0 a page can also
|
|
508
|
+
name the global that holds its handlers. Both are below.
|
|
425
509
|
|
|
426
510
|
```html
|
|
427
511
|
<script type="application/mcp-static-primitives+json">
|
|
428
|
-
{
|
|
512
|
+
{
|
|
513
|
+
"name": "org.example/your-page",
|
|
514
|
+
"remotes": [ { "type": "page-global", "global": "mcp" } ],
|
|
515
|
+
"_meta": { "io.modelcontextprotocol/static-primitives": {
|
|
429
516
|
"tools": [ { "name": "set_text", "description": "…", "inputSchema": { } } ],
|
|
430
517
|
"prompts": [ { "name": "annotate", "description": "Annotate this page" } ],
|
|
431
518
|
"resources": [ { "uri": "page:text", "name": "Text", "description": "the text to annotate",
|
|
432
519
|
"volatility": "volatile" } ]
|
|
433
|
-
} }
|
|
520
|
+
} }
|
|
521
|
+
}
|
|
434
522
|
</script>
|
|
435
523
|
```
|
|
436
524
|
|
|
437
|
-
|
|
438
|
-
|
|
525
|
+
`name` identifies the card, as it does for a server. `remotes` says where the
|
|
526
|
+
handlers live — see "Say where your handlers live" below. Only the `_meta`
|
|
527
|
+
block is required by this widget; the rest is what makes the card readable by
|
|
528
|
+
anything else.
|
|
529
|
+
|
|
530
|
+
The card says what exists. Three maps say what it does, and each one is keyed by
|
|
531
|
+
the identifier that the matching MCP operation takes:
|
|
439
532
|
|
|
440
533
|
| declared in the card | implemented in | keyed by |
|
|
441
534
|
|---|---|---|
|
|
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 |
|
|
535
|
+
| `tools` | `tools` under your declared global, else `window.aiActions` | tool `name`, as `tools/call` takes |
|
|
536
|
+
| `prompts` | `prompts` under it, else `window.aiPrompts` | prompt `name`, as `prompts/get` takes |
|
|
537
|
+
| `resources` | `resources` under it, else `window.aiResources` | resource `uri`, as `resources/read` takes |
|
|
445
538
|
|
|
446
539
|
```js
|
|
447
|
-
window.
|
|
448
|
-
|
|
449
|
-
|
|
540
|
+
window.mcp = {
|
|
541
|
+
tools: { set_text: (args) => { /* change the page */ } },
|
|
542
|
+
prompts: { annotate: (args) => "Annotate this: " + args.text },
|
|
543
|
+
resources: { "page:text": () => document.querySelector("#text").value }
|
|
544
|
+
}
|
|
450
545
|
```
|
|
451
546
|
|
|
452
547
|
A prompt handler may return a plain string or the full `messages` array that
|
|
@@ -466,6 +561,37 @@ fetch from the web by itself — which page resources are not. The form is
|
|
|
466
561
|
`page:<name>` with no path, because a page has only one namespace. Expect this to
|
|
467
562
|
change.
|
|
468
563
|
|
|
564
|
+
**Say where your handlers live.** A card can declare the global that holds
|
|
565
|
+
them, the way a Server Card declares its endpoint. Then a consumer that has
|
|
566
|
+
never heard of this widget can read your card, find your handlers and call
|
|
567
|
+
them:
|
|
568
|
+
|
|
569
|
+
```html
|
|
570
|
+
<script type="application/mcp-static-primitives+json">
|
|
571
|
+
{ "name": "org.example/your-page",
|
|
572
|
+
"remotes": [ { "type": "page-global", "global": "mcp" } ],
|
|
573
|
+
"_meta": { "io.modelcontextprotocol/static-primitives": { "tools": [ ... ] } } }
|
|
574
|
+
</script>
|
|
575
|
+
<script>
|
|
576
|
+
window.mcp = {
|
|
577
|
+
tools: { set_text: function (args) { } },
|
|
578
|
+
prompts: { annotate: function (args) { return "..." } },
|
|
579
|
+
resources: { "page:text": function () { return "..." } }
|
|
580
|
+
};
|
|
581
|
+
</script>
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
The name is yours to choose, and it is declared rather than assumed: this code
|
|
585
|
+
runs on your page, where a short fixed name such as `mcp` could collide with
|
|
586
|
+
something of your own.
|
|
587
|
+
|
|
588
|
+
Without that declaration the widget reads `window.aiActions`,
|
|
589
|
+
`window.aiPrompts` and `window.aiResources`, as before. Those names are known
|
|
590
|
+
only to this widget, and they can be renamed per page with the options below,
|
|
591
|
+
so no other consumer can rely on them. Each kind falls back on its own, so you
|
|
592
|
+
can move tools into the declared global and leave prompts and resources where
|
|
593
|
+
they are.
|
|
594
|
+
|
|
469
595
|
**The card is found by its media type.** A client matches
|
|
470
596
|
`script[type="application/mcp-static-primitives+json"]`, so the element needs no
|
|
471
597
|
`id`. This is how a client that was never told about your page can still read
|
|
@@ -491,15 +617,15 @@ loses the conversation. The transcript, the record of which tools ran, and the
|
|
|
491
617
|
model's own context are kept in `sessionStorage`: per tab, gone when the tab
|
|
492
618
|
closes, never sent anywhere.
|
|
493
619
|
|
|
494
|
-
The key is the page's **path**, not its full URL
|
|
495
|
-
|
|
496
|
-
different page starts a
|
|
620
|
+
The key is the page's **path**, not its full URL. A form submit that returns to
|
|
621
|
+
the same page with a new query string therefore keeps the same conversation.
|
|
622
|
+
Moving to a different page starts a new one. There is nothing to configure.
|
|
497
623
|
|
|
498
624
|
Two consequences worth knowing. An action of yours that navigates is now safe
|
|
499
625
|
to declare — before this, offering one meant offering to wipe the visitor's
|
|
500
|
-
chat. And if the navigation lands on an error page that
|
|
501
|
-
|
|
502
|
-
|
|
626
|
+
chat. And if the navigation lands on an error page that renders no widget, the
|
|
627
|
+
conversation is still in storage. There is no panel to show it until the visitor
|
|
628
|
+
returns to a page that has one.
|
|
503
629
|
|
|
504
630
|
## Choosing what answers the chat, and whose tools to offer
|
|
505
631
|
|
|
@@ -510,10 +636,10 @@ does is what its call site says.
|
|
|
510
636
|
```erb
|
|
511
637
|
<%# a hub answers the chat; its registered tools are offered too %>
|
|
512
638
|
<% hub = "https://your-meta-server.example" %>
|
|
513
|
-
<%= llm_meta_widget(llm_url: hub, tool_hub_url: hub, model: "
|
|
639
|
+
<%= llm_meta_widget(llm_url: hub, tool_hub_url: hub, model: "gpt-oss-120b") %>
|
|
514
640
|
|
|
515
641
|
<%# a hub answers the chat; no hub-registered tools %>
|
|
516
|
-
<%= llm_meta_widget(llm_url: hub, model: "
|
|
642
|
+
<%= llm_meta_widget(llm_url: hub, model: "gpt-oss-120b") %>
|
|
517
643
|
|
|
518
644
|
<%# the visitor's own Ollama answers; a hub still supplies its tools %>
|
|
519
645
|
<%= llm_meta_widget(llm_url: "http://localhost:11434", llm_provider: :ollama,
|
|
@@ -524,12 +650,14 @@ does is what its call site says.
|
|
|
524
650
|
model: "qwen3.8:27b") %>
|
|
525
651
|
```
|
|
526
652
|
|
|
527
|
-
**The Ollama path.** The widget POSTs to `{llm_url}/api/chat` and reads
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
653
|
+
**The Ollama path.** The widget POSTs to `{llm_url}/api/chat` and reads Ollama's
|
|
654
|
+
NDJSON stream. Tool schemas are sent in OpenAI's shape, and `tool_calls` come
|
|
655
|
+
back the same way, so Class 2 and Class 3 tools work exactly as they do against
|
|
656
|
+
a hub. The model picker lists `{llm_url}/api/tags`.
|
|
657
|
+
|
|
658
|
+
Ollama must be told to accept your page's origin:
|
|
659
|
+
`OLLAMA_ORIGINS=https://your-site.example`. This is the same CORS requirement
|
|
660
|
+
as the hub has, set in a different place.
|
|
533
661
|
|
|
534
662
|
What you give up without a `tool_hub_url` is Class 1 only: tools registered on
|
|
535
663
|
somebody's hub. That is not "no tools" — page actions and your own
|
|
@@ -543,9 +671,9 @@ no retention policy to write.
|
|
|
543
671
|
## Declaring resources and prompts (static-primitives extension)
|
|
544
672
|
|
|
545
673
|
If you operate the MCP endpoint behind your `.well-known/mcp.json`, you can
|
|
546
|
-
|
|
547
|
-
input
|
|
548
|
-
context
|
|
674
|
+
offer two more things. **Prompts** are one-click starting points, shown as
|
|
675
|
+
buttons above the input. **Resources** are reference data the widget can put in
|
|
676
|
+
the model's context. The widget reads two optional fields the
|
|
549
677
|
`io.modelcontextprotocol/static-primitives` extension defines on a
|
|
550
678
|
`resources/list` entry:
|
|
551
679
|
|
|
@@ -554,9 +682,9 @@ context). The widget reads two optional fields the
|
|
|
554
682
|
"uri": "yourhost://catalog",
|
|
555
683
|
"name": "Catalog",
|
|
556
684
|
"mimeType": "application/json",
|
|
685
|
+
"size": 1298,
|
|
557
686
|
"_meta": {
|
|
558
687
|
"io.modelcontextprotocol/static-primitives": {
|
|
559
|
-
"sizeBytes": 1298,
|
|
560
688
|
"volatility": "stable",
|
|
561
689
|
"autoAttach": true
|
|
562
690
|
}
|
|
@@ -564,10 +692,14 @@ context). The widget reads two optional fields the
|
|
|
564
692
|
}
|
|
565
693
|
```
|
|
566
694
|
|
|
567
|
-
- **`
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
695
|
+
- **`size`** — the byte length of the payload `resources/read` returns. It is the
|
|
696
|
+
base MCP `Resource` field, not an extension field, so it sits at the top level
|
|
697
|
+
of the entry. Earlier versions of the extension carried `sizeBytes` inside
|
|
698
|
+
`_meta`; the widget still reads that when `size` is absent, but it will be
|
|
699
|
+
removed, so emit `size`.
|
|
700
|
+
The widget decides whether it can afford the resource *before* fetching it. If
|
|
701
|
+
the resource is over its budget of 8 KB, the bytes never cross the wire, and
|
|
702
|
+
the LLM asks for what it needs through your tools instead.
|
|
571
703
|
- **`volatility`** — `"stable"` (read once, re-used) or `"volatile"` (re-read
|
|
572
704
|
on every turn that uses it).
|
|
573
705
|
- **`autoAttach`** — may the widget put this in the model's context without
|
|
@@ -578,9 +710,9 @@ that predates the extension: the resource is fetched, trimmed to budget and
|
|
|
578
710
|
carried in the model's context.
|
|
579
711
|
|
|
580
712
|
A prompt is offered as a button labelled by its `title`. Declare its arguments
|
|
581
|
-
as **optional
|
|
582
|
-
visitor must
|
|
583
|
-
it
|
|
713
|
+
as **optional**, and fill them from page state where you can. An argument the
|
|
714
|
+
visitor must type in makes the button useless to the visitor who needed it most:
|
|
715
|
+
to press it, they would already have to know your form.
|
|
584
716
|
|
|
585
717
|
## Further reading
|
|
586
718
|
|
|
@@ -590,8 +722,9 @@ it most, since they must already know your form to press it.
|
|
|
590
722
|
current draft shape.
|
|
591
723
|
- **MCP itself** — <https://modelcontextprotocol.io>
|
|
592
724
|
- **A worked adopter** — PubDictionaries' annotation page
|
|
593
|
-
(<https://pubdictionaries.org/text_annotation>) runs this widget with all
|
|
594
|
-
|
|
725
|
+
(<https://pubdictionaries.org/text_annotation>) runs this widget with all three
|
|
726
|
+
tool classes, and declares its own tools, prompts and resources in one page
|
|
727
|
+
card. Its
|
|
595
728
|
`app/views/annotation/text_annotation.html.erb` and `app/controllers/mcp_controller.rb`
|
|
596
729
|
are the fullest example available.
|
|
597
730
|
- **Shipping the element** — `docs/custom-element-distribution.md` is the design note
|
|
@@ -561,8 +561,50 @@ function parseSseFrame(raw) {
|
|
|
561
561
|
}
|
|
562
562
|
var STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives";
|
|
563
563
|
var PAGE_CARD_MEDIA_TYPE = "application/mcp-static-primitives+json";
|
|
564
|
+
var PAGE_GLOBAL_TRANSPORT = "page-global";
|
|
565
|
+
function declaredPageGlobal(card) {
|
|
566
|
+
for (const remote of card && card.remotes || []) {
|
|
567
|
+
if (!remote || remote.type !== PAGE_GLOBAL_TRANSPORT) continue;
|
|
568
|
+
if (typeof remote.global === "string" && remote.global.trim() !== "") return remote.global.trim();
|
|
569
|
+
}
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
function pageHandlers({ card, scope = {}, legacy = {}, warn = console.warn } = {}) {
|
|
573
|
+
const name = declaredPageGlobal(card);
|
|
574
|
+
const declared = name ? scope[name] : null;
|
|
575
|
+
if (name && !declared) {
|
|
576
|
+
warn(`[llm-meta-widget] the page card declares global "${name}", but window.${name} is not defined \u2014 is the script that defines it loaded, and did it run?`);
|
|
577
|
+
}
|
|
578
|
+
const isMap = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
579
|
+
const pick = (kind, legacyName) => {
|
|
580
|
+
const fromGlobal = declared ? declared[kind] : void 0;
|
|
581
|
+
if (isMap(fromGlobal)) return fromGlobal;
|
|
582
|
+
if (declared && fromGlobal !== void 0 && !isMap(fromGlobal)) {
|
|
583
|
+
warn(`[llm-meta-widget] window.${name}.${kind} is ${Array.isArray(fromGlobal) ? "an array" : typeof fromGlobal}; it must be an object keyed by ${kind === "resources" ? "uri" : "name"}`);
|
|
584
|
+
}
|
|
585
|
+
return legacyName && scope[legacyName] || {};
|
|
586
|
+
};
|
|
587
|
+
return {
|
|
588
|
+
global: name,
|
|
589
|
+
tools: pick("tools", legacy.tools),
|
|
590
|
+
prompts: pick("prompts", legacy.prompts),
|
|
591
|
+
resources: pick("resources", legacy.resources)
|
|
592
|
+
};
|
|
593
|
+
}
|
|
564
594
|
function mergeStaticPrimitivesCards(cards, warn = console.warn) {
|
|
565
|
-
const merged = { tools: [], prompts: [], resources: [] };
|
|
595
|
+
const merged = { remotes: [], tools: [], prompts: [], resources: [] };
|
|
596
|
+
for (const card of cards || []) {
|
|
597
|
+
for (const remote of card && card.remotes || []) {
|
|
598
|
+
const clash = merged.remotes.find((r) => r.type === remote.type && r.global !== remote.global);
|
|
599
|
+
if (clash) {
|
|
600
|
+
warn(`[llm-meta-widget] two blocks declare different ${remote.type} globals ("${clash.global}" and "${remote.global}"); using "${clash.global}"`);
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
if (!merged.remotes.some((r) => r.type === remote.type && r.global === remote.global)) {
|
|
604
|
+
merged.remotes.push(remote);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
566
608
|
const keyOf = { tools: "name", prompts: "name", resources: "uri" };
|
|
567
609
|
for (const kind of ["tools", "prompts", "resources"]) {
|
|
568
610
|
const key = keyOf[kind];
|
|
@@ -580,7 +622,7 @@ function mergeStaticPrimitivesCards(cards, warn = console.warn) {
|
|
|
580
622
|
return merged;
|
|
581
623
|
}
|
|
582
624
|
function readStaticPrimitivesCard(text, warn = console.warn) {
|
|
583
|
-
const empty = { tools: [], prompts: [], resources: [] };
|
|
625
|
+
const empty = { remotes: [], tools: [], prompts: [], resources: [] };
|
|
584
626
|
if (typeof text !== "string" || text.trim() === "") return empty;
|
|
585
627
|
let doc;
|
|
586
628
|
try {
|
|
@@ -600,6 +642,8 @@ function readStaticPrimitivesCard(text, warn = console.warn) {
|
|
|
600
642
|
return ok;
|
|
601
643
|
});
|
|
602
644
|
return {
|
|
645
|
+
// The whole card's transport, so what it declares survives the parse.
|
|
646
|
+
remotes: Array.isArray(doc.remotes) ? doc.remotes : [],
|
|
603
647
|
tools: named(ext.tools, "tool"),
|
|
604
648
|
prompts: named(ext.prompts, "prompt"),
|
|
605
649
|
// Resources are addressed by uri, not name — resources/read takes the uri.
|
|
@@ -3931,6 +3975,11 @@ function boot(cfg) {
|
|
|
3931
3975
|
var pageCardEl = document.getElementById(PAGE_CARD_ID);
|
|
3932
3976
|
pageCard = readStaticPrimitivesCard(pageCardEl ? pageCardEl.textContent : null);
|
|
3933
3977
|
}
|
|
3978
|
+
var handlers = pageHandlers({
|
|
3979
|
+
card: pageCard,
|
|
3980
|
+
scope: window,
|
|
3981
|
+
legacy: { tools: ACTIONS_GLOBAL, prompts: PROMPTS_GLOBAL, resources: RESOURCES_GLOBAL }
|
|
3982
|
+
});
|
|
3934
3983
|
var actionsSchemaEl = document.getElementById(ACTIONS_SCHEMA_ID);
|
|
3935
3984
|
var localTools = pageCard.tools.length ? pageCard.tools : actionsSchemaEl ? JSON.parse(actionsSchemaEl.textContent) : [];
|
|
3936
3985
|
var remoteToolsEl = document.getElementById(REMOTE_TOOLS_SCHEMA_ID);
|
|
@@ -4207,7 +4256,7 @@ function boot(cfg) {
|
|
|
4207
4256
|
function argState() {
|
|
4208
4257
|
return promptArgState({
|
|
4209
4258
|
entries: pageCard.resources,
|
|
4210
|
-
resources:
|
|
4259
|
+
resources: handlers.resources,
|
|
4211
4260
|
state: window[STATE_GLOBAL] || {}
|
|
4212
4261
|
});
|
|
4213
4262
|
}
|
|
@@ -4307,7 +4356,7 @@ function boot(cfg) {
|
|
|
4307
4356
|
}
|
|
4308
4357
|
button.disabled = true;
|
|
4309
4358
|
try {
|
|
4310
|
-
var result = prompt.source === "page" ? await getPagePrompt({ name: prompt.name, args, prompts:
|
|
4359
|
+
var result = prompt.source === "page" ? await getPagePrompt({ name: prompt.name, args, prompts: handlers.prompts }) : await getMcpPrompt({ endpoint: prompt.endpoint, name: prompt.name, args });
|
|
4311
4360
|
var text = promptMessagesToText(result);
|
|
4312
4361
|
if (!text) {
|
|
4313
4362
|
appendTurn("error", "The server returned an empty prompt.");
|
|
@@ -4523,7 +4572,7 @@ function boot(cfg) {
|
|
|
4523
4572
|
resourceContext = turn.cached;
|
|
4524
4573
|
var page = await pageResourceLinesForTurn({
|
|
4525
4574
|
plans: pageResourcePlans,
|
|
4526
|
-
resources:
|
|
4575
|
+
resources: handlers.resources,
|
|
4527
4576
|
budgetBytes: RESOURCE_BUDGET_BYTES,
|
|
4528
4577
|
cache: pageResourceCache
|
|
4529
4578
|
});
|
|
@@ -4585,7 +4634,7 @@ function boot(cfg) {
|
|
|
4585
4634
|
localTools,
|
|
4586
4635
|
remoteTools,
|
|
4587
4636
|
hostWideTools,
|
|
4588
|
-
aiActions:
|
|
4637
|
+
aiActions: handlers.tools,
|
|
4589
4638
|
maxRounds: MAX_ROUNDS,
|
|
4590
4639
|
provider: LLM_PROVIDER === "ollama" ? "ollama" : "hub",
|
|
4591
4640
|
signal: currentAbort.signal,
|
|
@@ -884,6 +884,62 @@ export const STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives
|
|
|
884
884
|
// look cannot discover anything. SEP-0000, "HTML-delivery mode".
|
|
885
885
|
export const PAGE_CARD_MEDIA_TYPE = "application/mcp-static-primitives+json"
|
|
886
886
|
|
|
887
|
+
// The transport an HTML-delivered Server Card declares: a JavaScript global
|
|
888
|
+
// under which the handler maps live. A Server Card always states its endpoint
|
|
889
|
+
// rather than relying on a convention, and a page card states its global the
|
|
890
|
+
// same way — so a consumer that has never heard of this widget can parse the
|
|
891
|
+
// card, find the handlers and dispatch. The three legacy globals
|
|
892
|
+
// (aiActions/aiPrompts/aiResources) are names only this widget knows, and are
|
|
893
|
+
// configurable per page, so nothing outside could rely on them.
|
|
894
|
+
export const PAGE_GLOBAL_TRANSPORT = "page-global"
|
|
895
|
+
|
|
896
|
+
export function declaredPageGlobal(card) {
|
|
897
|
+
for (const remote of (card && card.remotes) || []) {
|
|
898
|
+
if (!remote || remote.type !== PAGE_GLOBAL_TRANSPORT) continue
|
|
899
|
+
if (typeof remote.global === "string" && remote.global.trim() !== "") return remote.global.trim()
|
|
900
|
+
}
|
|
901
|
+
return null
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Resolves the three handler maps. A declared global wins; each map falls back
|
|
905
|
+
// to its legacy global independently, so a page part-way through moving over
|
|
906
|
+
// keeps working instead of losing the primitives it has not moved yet.
|
|
907
|
+
export function pageHandlers({ card, scope = {}, legacy = {}, warn = console.warn } = {}) {
|
|
908
|
+
const name = declaredPageGlobal(card)
|
|
909
|
+
const declared = name ? scope[name] : null
|
|
910
|
+
|
|
911
|
+
// A card that names a global the page never defines is almost always a load
|
|
912
|
+
// order problem, or a script that threw before it got there. Falling back in
|
|
913
|
+
// silence hides that: the page then behaves as if it had declared nothing,
|
|
914
|
+
// and the first symptom is a prompt or tool that "has no handler".
|
|
915
|
+
if (name && !declared) {
|
|
916
|
+
warn(`[llm-meta-widget] the page card declares global "${name}", but window.${name} ` +
|
|
917
|
+
"is not defined — is the script that defines it loaded, and did it run?")
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const isMap = (v) => !!v && typeof v === "object" && !Array.isArray(v)
|
|
921
|
+
const pick = (kind, legacyName) => {
|
|
922
|
+
// `declared ? … : undefined`, not `declared && …`: the second yields null
|
|
923
|
+
// when nothing is declared, and `typeof null === "object"`, so the check
|
|
924
|
+
// below then warned about "window.null.tools" on every page with no card.
|
|
925
|
+
const fromGlobal = declared ? declared[kind] : undefined
|
|
926
|
+
if (isMap(fromGlobal)) return fromGlobal
|
|
927
|
+
// An array is the mistake worth naming: it passes a typeof check, so it
|
|
928
|
+
// would be used as a map and answer every lookup with undefined.
|
|
929
|
+
if (declared && fromGlobal !== undefined && !isMap(fromGlobal)) {
|
|
930
|
+
warn(`[llm-meta-widget] window.${name}.${kind} is ${Array.isArray(fromGlobal) ? "an array" : typeof fromGlobal}; ` +
|
|
931
|
+
`it must be an object keyed by ${kind === "resources" ? "uri" : "name"}`)
|
|
932
|
+
}
|
|
933
|
+
return (legacyName && scope[legacyName]) || {}
|
|
934
|
+
}
|
|
935
|
+
return {
|
|
936
|
+
global: name,
|
|
937
|
+
tools: pick("tools", legacy.tools),
|
|
938
|
+
prompts: pick("prompts", legacy.prompts),
|
|
939
|
+
resources: pick("resources", legacy.resources)
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
887
943
|
// Several blocks may appear in one document, so that a page assembled from
|
|
888
944
|
// components can let each component declare its own primitives. The arrays
|
|
889
945
|
// concatenate by primitive type.
|
|
@@ -894,7 +950,23 @@ export const PAGE_CARD_MEDIA_TYPE = "application/mcp-static-primitives+json"
|
|
|
894
950
|
// Picking the first would make which component wins depend on DOM order, which
|
|
895
951
|
// is not something a page author controls when the components are partials.
|
|
896
952
|
export function mergeStaticPrimitivesCards(cards, warn = console.warn) {
|
|
897
|
-
const merged = { tools: [], prompts: [], resources: [] }
|
|
953
|
+
const merged = { remotes: [], tools: [], prompts: [], resources: [] }
|
|
954
|
+
// Each block is a full Server Card and may declare a transport. The first
|
|
955
|
+
// one wins; a second block naming a DIFFERENT global is an authoring error,
|
|
956
|
+
// because only one of them can be dispatched to.
|
|
957
|
+
for (const card of cards || []) {
|
|
958
|
+
for (const remote of (card && card.remotes) || []) {
|
|
959
|
+
const clash = merged.remotes.find((r) => r.type === remote.type && r.global !== remote.global)
|
|
960
|
+
if (clash) {
|
|
961
|
+
warn(`[llm-meta-widget] two blocks declare different ${remote.type} globals ` +
|
|
962
|
+
`("${clash.global}" and "${remote.global}"); using "${clash.global}"`)
|
|
963
|
+
continue
|
|
964
|
+
}
|
|
965
|
+
if (!merged.remotes.some((r) => r.type === remote.type && r.global === remote.global)) {
|
|
966
|
+
merged.remotes.push(remote)
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
898
970
|
const keyOf = { tools: "name", prompts: "name", resources: "uri" }
|
|
899
971
|
|
|
900
972
|
for (const kind of [ "tools", "prompts", "resources" ]) {
|
|
@@ -917,7 +989,7 @@ export function mergeStaticPrimitivesCards(cards, warn = console.warn) {
|
|
|
917
989
|
}
|
|
918
990
|
|
|
919
991
|
export function readStaticPrimitivesCard(text, warn = console.warn) {
|
|
920
|
-
const empty = { tools: [], prompts: [], resources: [] }
|
|
992
|
+
const empty = { remotes: [], tools: [], prompts: [], resources: [] }
|
|
921
993
|
if (typeof text !== "string" || text.trim() === "") return empty
|
|
922
994
|
|
|
923
995
|
let doc
|
|
@@ -941,6 +1013,8 @@ export function readStaticPrimitivesCard(text, warn = console.warn) {
|
|
|
941
1013
|
})
|
|
942
1014
|
|
|
943
1015
|
return {
|
|
1016
|
+
// The whole card's transport, so what it declares survives the parse.
|
|
1017
|
+
remotes: Array.isArray(doc.remotes) ? doc.remotes : [],
|
|
944
1018
|
tools: named(ext.tools, "tool"),
|
|
945
1019
|
prompts: named(ext.prompts, "prompt"),
|
|
946
1020
|
// Resources are addressed by uri, not name — resources/read takes the uri.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aibranch/llm-meta-widget",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
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": [
|