@aibranch/llm-meta-widget 0.7.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.
@@ -0,0 +1,248 @@
1
+ # Design note — distributing the widget as a custom element
2
+
3
+ **Status:** implemented 2026-10-03 (same day), except for npm/CDN
4
+ publication, which needs an npm account. Written as a proposal; kept as the
5
+ record of why the shape is what it is.
6
+ **Problem owner:** adopters whose host application is not Rails.
7
+
8
+ **What landed, and what did not**
9
+
10
+ Landed: `element.js` (the panel, moved), `config.js` (attribute reading, with
11
+ the first unit tests the panel logic has ever had), `panel.css` (extracted),
12
+ `script/build.mjs` (esbuild bundle plus a staleness check that `npm test`
13
+ runs), a route and controller action serving the bundle, and the partial
14
+ reduced to the element tag. The gem keeps its helper signature, so
15
+ PubDictionaries needs no edit.
16
+
17
+ Not landed: the npm package and the CDN URL, so the two-line integration in
18
+ the example below currently means "load the file the gem serves, or
19
+ self-host it". Everything else about it is true today.
20
+
21
+ Verified on a plain HTML page served by `python3 -m http.server`, with no
22
+ Rails anywhere: the element upgrades and builds its own DOM, injects its own
23
+ styles (the host serves no stylesheet), keeps the floating `position: fixed`
24
+ geometry, reads its configuration from attributes, discovers hub-registered
25
+ tools cross-origin, and completes a real streamed turn against
26
+ `qwen3-8-27b-fast` that answers from the host page's own `aiState` reader.
27
+
28
+ Two findings worth keeping. The model picker reassigns `MODEL`
29
+ (`MODEL = modelPicker.value`), so the sixteen configuration values are bound
30
+ with `var` rather than destructured as `const` — the latter throws the moment
31
+ a visitor changes model, and nothing before the browser would catch it. And
32
+ the 985 lines of panel logic contain no ERB at all, which is why they moved
33
+ verbatim rather than being rewritten.
34
+
35
+ ## Why
36
+
37
+ The widget is a Rails engine. Integration means `bundle add llm_meta_widget`, a
38
+ helper call that renders `app/views/llm_meta_widget/_chat_panel.html.erb`, and
39
+ engine-served routes under `/llm_meta_widget_assets/`. A host that is not Rails
40
+ can use none of that.
41
+
42
+ This stopped being hypothetical. The TogoMCP maintainer
43
+ (`togomcp.rdfportal.org`, Python/ASGI under uvicorn) wants the widget on their
44
+ front page, using hub-registered tools only. The options available today are all
45
+ bad: stand up a Rails front door beside their app, reimplement the panel
46
+ template in Jinja, or accept a hand-rendered HTML fragment plus three files
47
+ copied out of the gem — generated, unmaintained, and silently stale the moment
48
+ the gem moves.
49
+
50
+ ## What the code actually is
51
+
52
+ Measured on the current panel partial, because it decides how big this job is:
53
+
54
+ | | |
55
+ |---|---|
56
+ | inline `<script>` | 1,454 lines |
57
+ | `<style>` | 360 lines |
58
+ | markup | **21 lines** |
59
+ | Rails helper calls | **1** (`orchestrator_path`) |
60
+ | `document.getElementById` | **4** |
61
+ | `document.createElement` | 35 (scope-independent) |
62
+
63
+ The panel is not a template that happens to contain script. It is a program
64
+ that happens to live in an `.erb` file. Everything Rails-specific is the one
65
+ helper call and ~15 `to_json` local injections. That makes this a packaging
66
+ change, not a port.
67
+
68
+ ## Proposal
69
+
70
+ Ship the client as a **custom element** in a single bundled ES module, published
71
+ to npm and served from a CDN. Integration in any stack, or none:
72
+
73
+ ```html
74
+ <script type="module"
75
+ src="https://cdn.jsdelivr.net/npm/@aibranch/llm-meta-widget@0.7"></script>
76
+ <llm-meta-widget llm-url="https://hub.aibranch.org"
77
+ tool-hub-url="https://hub.aibranch.org"
78
+ model="qwen3-8-27b-fast"
79
+ hub-tools="TogoMCP"></llm-meta-widget>
80
+ ```
81
+
82
+ The host serves no files and needs no CORS arrangement for the assets, because
83
+ jsdelivr serves them permissively. `orchestrator-path` disappears as an option:
84
+ the bundle is self-contained.
85
+
86
+ **Node's role is build and distribution, not integration.** esbuild bundles the
87
+ panel script, `orchestrator.js`, the vendored `marked`, and the CSS into one
88
+ module; npm plus a CDN ships it. npm alone would not help a Python host — a
89
+ Python project does not want a Node toolchain. The custom element is what makes
90
+ it framework-neutral.
91
+
92
+ ## Attribute surface
93
+
94
+ Every current helper option maps to an attribute. Three cases need care.
95
+
96
+ | helper option | attribute | type |
97
+ |---|---|---|
98
+ | `llm_url:` | `llm-url` | string, required |
99
+ | `model:` | `model` | string, required |
100
+ | `tool_hub_url:` | `tool-hub-url` | string; absent = no Class 1 |
101
+ | `llm_provider:` | `llm-provider` | `llm_meta_server` \| `ollama` |
102
+ | `api_key_uuid:` | `api-key-uuid` | string |
103
+ | `greeting:` | `greeting` | string |
104
+ | `max_rounds:` | `max-rounds` | integer |
105
+ | `actions_schema_id:` | `actions-schema-id` | string |
106
+ | `state_global:` / `actions_global:` | `state-global` / `actions-global` | string |
107
+ | `remote_tools_schema_id:` | `remote-tools-schema-id` | string |
108
+ | `models:` / `hub_tools:` | `models` / `hub-tools` | comma-separated; absent = no allowlist |
109
+ | `enable_model_picker:` / `enable_tool_picker:` | `enable-model-picker` / `enable-tool-picker` | **see below** |
110
+ | `well_known_urls:` | `well-known-urls` | **see below** |
111
+ | `orchestrator_path:` | — | dropped, bundle is self-contained |
112
+
113
+ **Pickers are value attributes, not HTML boolean attributes.** Both default to
114
+ `true`, so presence-based semantics would be backwards — you would have to add
115
+ an attribute to get the default. Disable with `enable-tool-picker="false"`.
116
+ Anything other than the exact string `false` is true.
117
+
118
+ **`well-known-urls` is tri-state and the trap in this design.** The Ruby option
119
+ distinguishes `nil` (auto-discover same-origin `/.well-known/mcp.json`) from
120
+ `[]` (disable discovery entirely) from an explicit array. Attributes cannot
121
+ express nil versus empty, so:
122
+
123
+ - attribute **absent** → auto-discover same-origin (the `nil` default)
124
+ - `well-known-urls=""` → discovery **off** (the `[]` case)
125
+ - `well-known-urls="https://a.example/.well-known/mcp.json,…"` → fetch those
126
+
127
+ This asymmetry must be documented wherever the attribute is, because the quiet
128
+ failure — expecting discovery off and getting a same-origin fetch — looks like
129
+ nothing at all.
130
+
131
+ ## What does not change
132
+
133
+ `window.aiState` and `window.aiActions` stay global. They are the host's
134
+ contract for page-embedded actions (Class 3), and a host declaring them on
135
+ `window` is correct and framework-neutral already. The `#ai-actions` JSON block
136
+ stays a page-level `<script type="application/json">`. `sessionStorage`
137
+ transcript persistence is unaffected.
138
+
139
+ ## Decision: no shadow DOM in v1 (implemented as decided)
140
+
141
+ Shadow DOM is the reflex choice and would buy style isolation. Reject it for the
142
+ first version:
143
+
144
+ - It breaks every selector, by two routes. Six of the seven Selenium e2e tests
145
+ reach in with `document.querySelector` / `getElementById` inside
146
+ `execute_script`; and all seven use Selenium's own `find_element(css: …)` /
147
+ `find_element(id: …)`, which does not pierce shadow DOM either. So the whole
148
+ e2e suite needs rewriting, not just the scripted parts.
149
+ - Adopters lose the ability to theme the widget with their own CSS, which some
150
+ will want.
151
+ - Without it, the refactor is mostly *moving* code: 21 lines of markup become a
152
+ template string, the style block is injected, the ERB-injected `var`s become
153
+ attribute reads, and 4 lookups re-scope to the element.
154
+
155
+ Styles stay global exactly as they are today, so there is no behaviour change to
156
+ reason about. Revisit if style collisions prove real in the field.
157
+
158
+ ## The gem stays, as a wrapper
159
+
160
+ `llm_meta_widget(...)` keeps its signature and renders
161
+ `<llm-meta-widget …>` with the mapped attributes, vendoring the same built
162
+ module so Rails hosts need no CDN. One implementation, two delivery paths —
163
+ rather than two implementations drifting.
164
+
165
+ ## Publishing to npm
166
+
167
+ Published as **`@aibranch/llm-meta-widget`** under the `aibranch` org — a
168
+ separate namespace from `@pubann`, matching the deployed `aibranch.org`
169
+ hostnames, because the widget is the generic client and PubDictionaries is one
170
+ adopter of it.
171
+
172
+ The CDN URL has no path:
173
+
174
+ ```
175
+ https://cdn.jsdelivr.net/npm/@aibranch/llm-meta-widget@0.7
176
+ ```
177
+
178
+ `main` and `exports["."]` both point at the bundle, so jsdelivr resolves the
179
+ bare specifier to it. A second export, `./orchestrator`, is deliberate: the
180
+ orchestrator is the public JS API for anyone who wants to build their own UI on
181
+ top instead of using the element.
182
+
183
+ `sideEffects: true` matters more than it looks. The bundle's entire purpose is
184
+ its side effects — defining the custom element and injecting the styles — so
185
+ declaring it side-effect-free would licence a bundler to drop an import that
186
+ exists for nothing else.
187
+
188
+ `files` ships only the two consumable modules and this note. The ERB partial,
189
+ the Ruby helper and the unbundled sources are the gem's business; npm adds
190
+ README and LICENSE on its own. Result: 6 files, ~69 kB packed.
191
+
192
+ `publishConfig.access: "public"` is set because a **scoped package publishes
193
+ private by default**, which fails outright on a free org. Having it in the file
194
+ means nobody has to remember `--access public` at the worst moment.
195
+
196
+ **The release order is forced by npm, not by preference.** npm has no
197
+ pending-publisher, so trusted publishing cannot be configured until the package
198
+ exists:
199
+
200
+ 1. `npm login` as a member of the org, then `npm publish` by hand — the one
201
+ time a human credential is used.
202
+ 2. Add the Trusted Publisher in the package's npm settings: GitHub org `jdkim`,
203
+ repo `llm_meta_widget`, and the workflow filename, exactly.
204
+ 3. Later releases run from Actions with `permissions: id-token: write`, no
205
+ stored token. GitHub-hosted runners only; self-hosted is unsupported.
206
+
207
+ Note the asymmetry: the trusted publisher keys on the **GitHub** repo, which
208
+ lives under `jdkim/`, while the npm scope is `@aibranch`. That works — they are
209
+ different namespaces — but it is worth knowing before someone goes looking for
210
+ an `aibranch` GitHub org that does not exist.
211
+
212
+ `prepublishOnly` runs the build, the JS tests and the linter, but **not** the
213
+ Ruby test: a publish may run on a runner with no Ruby, and a release must not
214
+ fail on a missing interpreter. `npm test` runs both locally.
215
+
216
+ ## Risks
217
+
218
+ **The refactor touches the least-tested half of the codebase.** The 107 node
219
+ tests cover `orchestrator.js`. The panel has ESLint and the browser e2e runs,
220
+ nothing more — and this moves 1,454 lines of it. Add node tests for attribute
221
+ parsing (including the two traps above) and element construction *before*
222
+ moving any code.
223
+
224
+ **Version sync.** The gem version and the npm version must not drift. Either one
225
+ release process drives both, or the gem pins an exact npm version. Two
226
+ independently published artifacts of the same client is a bug waiting to be
227
+ filed.
228
+
229
+ **Breaking change for existing hosts** if the helper's rendered shape changes.
230
+ PubDictionaries is the only adopter today and would need a coordinated bump;
231
+ its `submit_annotation` and page-action wiring must be re-verified against the
232
+ element, since that is where host↔widget coupling actually lives.
233
+
234
+ **CDN as a dependency.** A CDN outage becomes a widget outage for hosts that use
235
+ it. Document self-hosting the single file as the supported alternative; it is
236
+ one static asset.
237
+
238
+ ## Open questions
239
+
240
+ - Does `<llm-meta-widget>` need to support more than one instance per page?
241
+ Today the panel assumes a single instance (fixed ids, one `sessionStorage`
242
+ key). Multi-instance would need id scoping and a key suffix — probably not
243
+ worth it, but decide rather than inherit.
244
+ - Should the element emit DOM events (`llm-widget:turn-complete`, etc.) so hosts
245
+ can react without reaching into internals? Cheap to add at build time,
246
+ awkward to retrofit later.
247
+ - npm package name: `llm-meta-widget` is free to check, and should match the
248
+ gem name for discoverability.
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@aibranch/llm-meta-widget",
3
+ "version": "0.7.0",
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
+ "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
+ "keywords": [
7
+ "llm",
8
+ "chat-widget",
9
+ "custom-element",
10
+ "mcp",
11
+ "model-context-protocol",
12
+ "tool-calling"
13
+ ],
14
+ "license": "Apache-2.0",
15
+ "author": "Jin-Dong Kim",
16
+ "homepage": "https://github.com/jdkim/llm_meta_widget",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/jdkim/llm_meta_widget.git"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/jdkim/llm_meta_widget/issues"
23
+ },
24
+ "type": "module",
25
+ "main": "app/assets/javascripts/llm_meta_widget/llm-meta-widget.js",
26
+ "exports": {
27
+ ".": "./app/assets/javascripts/llm_meta_widget/llm-meta-widget.js",
28
+ "./orchestrator": "./app/assets/javascripts/llm_meta_widget/orchestrator.js"
29
+ },
30
+ "sideEffects": true,
31
+ "files": [
32
+ "app/assets/javascripts/llm_meta_widget/llm-meta-widget.js",
33
+ "app/assets/javascripts/llm_meta_widget/orchestrator.js",
34
+ "docs/custom-element-distribution.md"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "//packaging": "Why main/exports/sideEffects/files are set this way: docs/custom-element-distribution.md, 'Publishing to npm'.",
40
+ "scripts": {
41
+ "build": "node script/build.mjs",
42
+ "lint": "eslint .",
43
+ "test:js": "node script/build.mjs --check && node --test app/assets/javascripts/llm_meta_widget/",
44
+ "test:rb": "ruby test/partial_render_test.rb && ruby test/attribute_roundtrip_test.rb",
45
+ "test": "npm run test:js && npm run test:rb",
46
+ "prepublishOnly": "npm run build && npm run test:js && npm run lint"
47
+ },
48
+ "//prepublishOnly": "Deliberately test:js and not test:rb \u2014 publishing may run on a CI runner with no Ruby, and a release must not fail on a missing interpreter. The Ruby side is covered by `npm test` locally and by the gem's own release path.",
49
+ "devDependencies": {
50
+ "esbuild": "^0.28.2",
51
+ "eslint": "^9.0.0",
52
+ "globals": "^15.0.0"
53
+ }
54
+ }