llm_meta_widget 0.6.1 → 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.
- checksums.yaml +4 -4
- data/README.md +64 -2
- data/app/assets/javascripts/llm_meta_widget/config.js +79 -0
- data/app/assets/javascripts/llm_meta_widget/element.js +1133 -0
- data/app/assets/javascripts/llm_meta_widget/llm-meta-widget.js +4429 -0
- data/app/assets/javascripts/llm_meta_widget/orchestrator.js +3 -3
- data/app/assets/stylesheets/llm_meta_widget/panel.css +363 -0
- data/app/controllers/llm_meta_widget/assets_controller.rb +11 -0
- data/app/helpers/llm_meta_widget/widget_helper.rb +34 -5
- data/app/views/llm_meta_widget/_chat_panel.html.erb +39 -1466
- data/config/routes.rb +8 -0
- data/lib/llm_meta_widget/version.rb +1 -1
- metadata +5 -1
|
@@ -0,0 +1,1133 @@
|
|
|
1
|
+
// The chat panel as a custom element.
|
|
2
|
+
//
|
|
3
|
+
// Previously this lived as inline script inside _chat_panel.html.erb, which
|
|
4
|
+
// made the widget a Rails-only artefact: a host needed the engine to render the
|
|
5
|
+
// template and to serve the assets. The logic was never Rails-specific — the
|
|
6
|
+
// template contributed one helper call and sixteen injected `var`s — so it now
|
|
7
|
+
// lives here, reads its configuration from attributes, and is bundled into a
|
|
8
|
+
// single ES module any host can load:
|
|
9
|
+
//
|
|
10
|
+
// <script type="module" src=".../llm-meta-widget.js"></script>
|
|
11
|
+
// <llm-meta-widget llm-url="https://hub.example" model="qwen3-8-27b-fast">
|
|
12
|
+
// </llm-meta-widget>
|
|
13
|
+
//
|
|
14
|
+
// No shadow DOM, deliberately: the styles are global today, the e2e suite and
|
|
15
|
+
// adopters both reach in with ordinary selectors, and isolation would break
|
|
16
|
+
// every one of them for no benefit yet. See docs/custom-element-distribution.md.
|
|
17
|
+
//
|
|
18
|
+
// The host page's contract is unchanged — `window.aiState`, `window.aiActions`
|
|
19
|
+
// and the `#ai-actions` JSON block are still page-level globals, because they
|
|
20
|
+
// belong to the host, not to this element.
|
|
21
|
+
import { runChatLoop, fetchMcpManifest, listMcpPrompts, getMcpPrompt,
|
|
22
|
+
listMcpResources, readMcpResource, promptMessagesToText,
|
|
23
|
+
loadHostResource, resourceLinesForTurn, resolvePromptArguments,
|
|
24
|
+
promptButtonProps, promptArgumentSummary, fetchOllamaModels,
|
|
25
|
+
createConversationStore, conversationKeyFor } from "./orchestrator.js";
|
|
26
|
+
import { marked } from "./marked.esm.js";
|
|
27
|
+
import { readConfig } from "./config.js";
|
|
28
|
+
import conversationCss from "../../stylesheets/llm_meta_widget/conversation.css";
|
|
29
|
+
import panelCss from "../../stylesheets/llm_meta_widget/panel.css";
|
|
30
|
+
|
|
31
|
+
// Standard prose settings — GFM (tables, autolinks, strikethrough), break
|
|
32
|
+
// single newlines into <br> so streamed LLM output that uses bare newlines
|
|
33
|
+
// still reads naturally.
|
|
34
|
+
marked.setOptions({ gfm: true, breaks: true });
|
|
35
|
+
|
|
36
|
+
const MARKUP = `
|
|
37
|
+
<button type="button" id="llm-meta-widget-toggle" title="Open AI assistant" aria-label="Open AI assistant">
|
|
38
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
|
39
|
+
<path d="M2 4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2h-3l-3.5 3.5V12H4a2 2 0 0 1-2-2V4Zm3 2a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm3 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm3 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/>
|
|
40
|
+
</svg>
|
|
41
|
+
</button>
|
|
42
|
+
|
|
43
|
+
<div id="llm-meta-widget-chat" class="llm-meta-conversation">
|
|
44
|
+
<div class="lmw-header">
|
|
45
|
+
<span class="lmw-title">AI assistant</span>
|
|
46
|
+
<div class="lmw-header-right">
|
|
47
|
+
<button type="button" class="lmw-clear" title="Clear conversation">clear</button>
|
|
48
|
+
<button type="button" class="lmw-hide" title="Hide">−</button>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="lmw-messages"></div>
|
|
52
|
+
<div class="lmw-input-container">
|
|
53
|
+
host MCP server actually offers one, so a tools-only server leaves
|
|
54
|
+
the widget exactly as it was. %>
|
|
55
|
+
<div class="lmw-prompts" style="display:none"></div>
|
|
56
|
+
<form class="lmw-form">
|
|
57
|
+
<div class="lmw-input-wrapper">
|
|
58
|
+
<textarea class="lmw-input" placeholder="Enter your message..." rows="2" autocomplete="off"></textarea>
|
|
59
|
+
<button type="submit" class="lmw-send" title="Send message">
|
|
60
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
|
61
|
+
<path d="M15.964.686a.5.5 0 0 0-.65-.65L.293 6.011a.513.513 0 0 0 .002.947l4.708 1.878 8.94-6.94-6.94 8.94 1.879 4.708c.163.407.756.416.951.016l6.13-13.884Z"/>
|
|
62
|
+
</svg>
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
<% if enable_model_picker || enable_tool_picker %>
|
|
66
|
+
<div class="lmw-input-controls">
|
|
67
|
+
<% if enable_model_picker %>
|
|
68
|
+
<select class="lmw-model-picker" title="Select model" aria-label="Select model" style="display:none">
|
|
69
|
+
</select>
|
|
70
|
+
<% end %>
|
|
71
|
+
<% if enable_tool_picker %>
|
|
72
|
+
<details class="lmw-tools-picker">
|
|
73
|
+
<summary>
|
|
74
|
+
🔧 Tools
|
|
75
|
+
<span class="lmw-tools-count lmw-tools-count-zero">0</span>
|
|
76
|
+
</summary>
|
|
77
|
+
<div class="lmw-tools-list">
|
|
78
|
+
<div class="lmw-tools-empty">Loading…</div>
|
|
79
|
+
</div>
|
|
80
|
+
</details>
|
|
81
|
+
<% end %>
|
|
82
|
+
</div>
|
|
83
|
+
<% end %>
|
|
84
|
+
</form>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
let stylesInjected = false;
|
|
90
|
+
function injectStylesOnce(doc) {
|
|
91
|
+
if (stylesInjected) return;
|
|
92
|
+
stylesInjected = true;
|
|
93
|
+
const style = doc.createElement("style");
|
|
94
|
+
style.setAttribute("data-llm-meta-widget", "styles");
|
|
95
|
+
style.textContent = conversationCss + "\n" + panelCss;
|
|
96
|
+
doc.head.appendChild(style);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class LlmMetaWidgetElement extends HTMLElement {
|
|
100
|
+
connectedCallback() {
|
|
101
|
+
if (this.__lmwWired) return; // moving the node must not re-boot
|
|
102
|
+
this.__lmwWired = true;
|
|
103
|
+
|
|
104
|
+
if (!this.getAttribute("llm-url") || !this.getAttribute("model")) {
|
|
105
|
+
console.error("[llm-meta-widget] llm-url and model are both required attributes; not starting.");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// The panel uses fixed element ids, so a second instance would fight the
|
|
109
|
+
// first over every lookup. One per page; see the design note's open
|
|
110
|
+
// question on multi-instance support.
|
|
111
|
+
if (document.getElementById("llm-meta-widget-chat")) {
|
|
112
|
+
console.warn("[llm-meta-widget] a widget is already on this page; ignoring this one.");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
injectStylesOnce(document);
|
|
117
|
+
this.insertAdjacentHTML("beforeend", MARKUP);
|
|
118
|
+
boot(readConfig(this));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (typeof customElements !== "undefined" && !customElements.get("llm-meta-widget")) {
|
|
123
|
+
customElements.define("llm-meta-widget", LlmMetaWidgetElement);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Everything below is the panel's original logic, moved verbatim from the ERB
|
|
127
|
+
// template. The only change is the sixteen bindings above it, which the
|
|
128
|
+
// template used to inject.
|
|
129
|
+
function boot(cfg) {
|
|
130
|
+
var LLM_BASE = cfg.LLM_BASE;
|
|
131
|
+
var TOOL_HUB_BASE = cfg.TOOL_HUB_BASE;
|
|
132
|
+
var API_KEY_UUID = cfg.API_KEY_UUID;
|
|
133
|
+
var MODEL = cfg.MODEL;
|
|
134
|
+
var ACTIONS_SCHEMA_ID = cfg.ACTIONS_SCHEMA_ID;
|
|
135
|
+
var GREETING = cfg.GREETING;
|
|
136
|
+
var STATE_GLOBAL = cfg.STATE_GLOBAL;
|
|
137
|
+
var ACTIONS_GLOBAL = cfg.ACTIONS_GLOBAL;
|
|
138
|
+
var REMOTE_TOOLS_SCHEMA_ID = cfg.REMOTE_TOOLS_SCHEMA_ID;
|
|
139
|
+
var MAX_ROUNDS = cfg.MAX_ROUNDS;
|
|
140
|
+
var WELL_KNOWN_URLS = cfg.WELL_KNOWN_URLS;
|
|
141
|
+
var LLM_PROVIDER = cfg.LLM_PROVIDER;
|
|
142
|
+
var ENABLE_MODEL_PICKER = cfg.ENABLE_MODEL_PICKER;
|
|
143
|
+
var ENABLE_TOOL_PICKER = cfg.ENABLE_TOOL_PICKER;
|
|
144
|
+
var MODEL_ALLOWLIST = cfg.MODEL_ALLOWLIST;
|
|
145
|
+
var HUB_TOOLS_ALLOWLIST = cfg.HUB_TOOLS_ALLOWLIST;
|
|
146
|
+
|
|
147
|
+
var root = document.getElementById("llm-meta-widget-chat");
|
|
148
|
+
var toggleBtn = document.getElementById("llm-meta-widget-toggle");
|
|
149
|
+
var historyEl = root.querySelector(".lmw-messages");
|
|
150
|
+
var formEl = root.querySelector(".lmw-form");
|
|
151
|
+
var inputEl = root.querySelector(".lmw-input");
|
|
152
|
+
var clearBtn = root.querySelector(".lmw-clear");
|
|
153
|
+
var hideBtn = root.querySelector(".lmw-hide");
|
|
154
|
+
var modelPicker = root.querySelector(".lmw-model-picker"); // null when disabled
|
|
155
|
+
var toolsPicker = root.querySelector(".lmw-tools-picker"); // null when disabled
|
|
156
|
+
var toolsListEl = root.querySelector(".lmw-tools-list"); // null when disabled
|
|
157
|
+
var toolsCountEl = root.querySelector(".lmw-tools-count"); // null when disabled
|
|
158
|
+
|
|
159
|
+
// Persistence keys — open/close state + resized geometry (width/height).
|
|
160
|
+
// Position is re-computed each open so the widget lands at bottom-right
|
|
161
|
+
// of the CURRENT viewport (not wherever the user resized it last time).
|
|
162
|
+
// Size is preserved because that's a user preference; position is a
|
|
163
|
+
// spawn convention.
|
|
164
|
+
var STORAGE_KEY_OPEN = "llm_meta_widget:open";
|
|
165
|
+
var STORAGE_KEY_SIZE = "llm_meta_widget:size"; // stored as "WxH" in px
|
|
166
|
+
|
|
167
|
+
// Floors matching the CSS min-width/min-height (24em / 16em). A panel
|
|
168
|
+
// measured mid-layout reports a few pixels; saving that produced a sliver
|
|
169
|
+
// anchored off the bottom of the window, and — worse — it was stored, so
|
|
170
|
+
// every later visit reopened at the sliver size. Sizes below the floor are
|
|
171
|
+
// neither saved nor honoured, which also heals a value already stored.
|
|
172
|
+
var MIN_SAVED_WIDTH = 320;
|
|
173
|
+
var MIN_SAVED_HEIGHT = 240;
|
|
174
|
+
|
|
175
|
+
function plausibleSize(w, h) {
|
|
176
|
+
return w >= MIN_SAVED_WIDTH && h >= MIN_SAVED_HEIGHT &&
|
|
177
|
+
w <= window.innerWidth * 2 && h <= window.innerHeight * 2;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function applyStoredSize() {
|
|
181
|
+
try {
|
|
182
|
+
var raw = localStorage.getItem(STORAGE_KEY_SIZE);
|
|
183
|
+
if (!raw) return;
|
|
184
|
+
var parts = raw.split("x");
|
|
185
|
+
var w = parseInt(parts[0], 10), h = parseInt(parts[1], 10);
|
|
186
|
+
if (!plausibleSize(w, h)) {
|
|
187
|
+
localStorage.removeItem(STORAGE_KEY_SIZE); // heal a bad value
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
root.style.width = w + "px";
|
|
191
|
+
root.style.height = h + "px";
|
|
192
|
+
} catch (e) { /* noop */ }
|
|
193
|
+
}
|
|
194
|
+
function saveCurrentSize() {
|
|
195
|
+
try {
|
|
196
|
+
var rect = root.getBoundingClientRect();
|
|
197
|
+
var w = Math.round(rect.width), h = Math.round(rect.height);
|
|
198
|
+
if (!plausibleSize(w, h)) return; // layout noise, not a choice
|
|
199
|
+
localStorage.setItem(STORAGE_KEY_SIZE, w + "x" + h);
|
|
200
|
+
} catch (e) { /* noop */ }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Anchor the widget at bottom-right of the viewport, given whatever
|
|
204
|
+
// size it currently has. Uses top/left in pixels (required for CSS
|
|
205
|
+
// `resize: both` — a bottom/right-pinned element can't be dragged out
|
|
206
|
+
// past the viewport edge to grow).
|
|
207
|
+
function positionAtBottomRight(attempt) {
|
|
208
|
+
var margin = 16; // ~1em from the viewport edges
|
|
209
|
+
var rect = root.getBoundingClientRect();
|
|
210
|
+
var top = Math.max(margin, window.innerHeight - rect.height - margin);
|
|
211
|
+
var left = Math.max(margin, window.innerWidth - rect.width - margin);
|
|
212
|
+
root.style.top = top + "px";
|
|
213
|
+
root.style.left = left + "px";
|
|
214
|
+
|
|
215
|
+
// Measured before layout settled: the panel reports a sliver and gets
|
|
216
|
+
// anchored to the very bottom of the window. Re-measure next frame.
|
|
217
|
+
var tries = attempt || 0;
|
|
218
|
+
if (rect.height < MIN_SAVED_HEIGHT && tries < 3 && typeof requestAnimationFrame === "function") {
|
|
219
|
+
requestAnimationFrame(function() { positionAtBottomRight(tries + 1); });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function setOpen(open) {
|
|
224
|
+
if (open) {
|
|
225
|
+
applyStoredSize(); // restore user's preferred size (if any)
|
|
226
|
+
root.classList.remove("lmw-collapsed");
|
|
227
|
+
toggleBtn.classList.add("lmw-hidden");
|
|
228
|
+
// Position AFTER unhiding so getBoundingClientRect returns real size.
|
|
229
|
+
positionAtBottomRight();
|
|
230
|
+
try { inputEl.focus(); } catch (e) { /* noop */ }
|
|
231
|
+
// Fire-and-forget the hub-picker fetch on first open; subsequent
|
|
232
|
+
// opens are no-ops (pickerLoaded flag). We deliberately don't
|
|
233
|
+
// await — the widget is immediately usable with the initial MODEL
|
|
234
|
+
// and no hub tools; pickers populate a moment later.
|
|
235
|
+
ensurePickerLoaded().catch(function(e) { console.warn("[widget] picker load failed:", e); });
|
|
236
|
+
} else {
|
|
237
|
+
saveCurrentSize(); // capture whatever the user resized to
|
|
238
|
+
root.classList.add("lmw-collapsed");
|
|
239
|
+
toggleBtn.classList.remove("lmw-hidden");
|
|
240
|
+
}
|
|
241
|
+
try { localStorage.setItem(STORAGE_KEY_OPEN, open ? "1" : "0"); } catch (e) { /* noop */ }
|
|
242
|
+
}
|
|
243
|
+
var initialOpen = false;
|
|
244
|
+
try { initialOpen = localStorage.getItem(STORAGE_KEY_OPEN) === "1"; } catch (e) { /* noop */ }
|
|
245
|
+
setOpen(initialOpen);
|
|
246
|
+
window.addEventListener("resize", function() {
|
|
247
|
+
if (!root.classList.contains("lmw-collapsed")) positionAtBottomRight();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
toggleBtn.addEventListener("click", function() { setOpen(true); });
|
|
251
|
+
hideBtn.addEventListener("click", function() { setOpen(false); });
|
|
252
|
+
|
|
253
|
+
// Persist size on every resize interaction. ResizeObserver fires
|
|
254
|
+
// continuously during a drag; save + reposition on each tick so the
|
|
255
|
+
// widget stays anchored to bottom-right as the user grows it.
|
|
256
|
+
if (typeof ResizeObserver === "function") {
|
|
257
|
+
var sawFirstResizeTick = false;
|
|
258
|
+
var ro = new ResizeObserver(function() {
|
|
259
|
+
if (root.classList.contains("lmw-collapsed")) return;
|
|
260
|
+
positionAtBottomRight();
|
|
261
|
+
// ResizeObserver always delivers an initial observation, and that
|
|
262
|
+
// one reports whatever the layout happened to be mid-boot. Only
|
|
263
|
+
// later ticks can be a user dragging the corner.
|
|
264
|
+
if (!sawFirstResizeTick) { sawFirstResizeTick = true; return; }
|
|
265
|
+
saveCurrentSize();
|
|
266
|
+
});
|
|
267
|
+
ro.observe(root);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// [{role, content, tools}] — grows across turns. `tools` is for the
|
|
271
|
+
// transcript only; the model is sent role and content.
|
|
272
|
+
var conversation = [];
|
|
273
|
+
|
|
274
|
+
// A page action that navigates used to take the conversation with it. The
|
|
275
|
+
// transcript lives in sessionStorage — per tab, gone when the tab closes —
|
|
276
|
+
// keyed by path, so submitting a form and landing back on the same page
|
|
277
|
+
// with a new query string keeps the thread.
|
|
278
|
+
var conversationStore = createConversationStore({
|
|
279
|
+
storage: window.sessionStorage,
|
|
280
|
+
key: conversationKeyFor(window.location)
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
function persistConversation() {
|
|
284
|
+
conversationStore.save({
|
|
285
|
+
turns: conversation,
|
|
286
|
+
open: !root.classList.contains("lmw-collapsed"),
|
|
287
|
+
model: MODEL
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
var actionsSchemaEl = document.getElementById(ACTIONS_SCHEMA_ID);
|
|
291
|
+
var localTools = actionsSchemaEl ? JSON.parse(actionsSchemaEl.textContent) : [];
|
|
292
|
+
var remoteToolsEl = document.getElementById(REMOTE_TOOLS_SCHEMA_ID);
|
|
293
|
+
// remoteTools is a mutable live list: starts from the schema-provided
|
|
294
|
+
// baseline (if any), then the level-1 picker adds/removes entries as
|
|
295
|
+
// the visitor toggles server checkboxes. Passed to runChatLoop on
|
|
296
|
+
// every submit so the current selection is respected turn-by-turn.
|
|
297
|
+
var remoteTools = remoteToolsEl ? JSON.parse(remoteToolsEl.textContent) : [];
|
|
298
|
+
|
|
299
|
+
// Level-1 picker caches. hubMcpServers[] is the list of anon-visible
|
|
300
|
+
// servers fetched from GET /api/mcp_servers; each server has an
|
|
301
|
+
// embedded `tools` array. selectedToolIds is the set of *individual*
|
|
302
|
+
// tool ids currently ON — same granularity as chat.aibranch.org's
|
|
303
|
+
// tool_selector: each tool can be toggled individually, and each
|
|
304
|
+
// server has a bulk-toggle checkbox that reflects/drives its children's
|
|
305
|
+
// state (all-on / indeterminate / all-off).
|
|
306
|
+
var hubMcpServers = [];
|
|
307
|
+
var selectedToolIds = new Set();
|
|
308
|
+
|
|
309
|
+
function anyAllowedByAllowlist(name, allowlist) {
|
|
310
|
+
return allowlist === null || allowlist.indexOf(name) >= 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function refreshRemoteToolsFromPicker() {
|
|
314
|
+
// Flatten every SELECTED tool (across all servers) into remoteTools,
|
|
315
|
+
// adapted to runChatLoop's expected { id, name, description, input_schema }.
|
|
316
|
+
var flat = [];
|
|
317
|
+
hubMcpServers.forEach(function(s) {
|
|
318
|
+
(s.tools || []).forEach(function(t) {
|
|
319
|
+
if (!selectedToolIds.has(t.id)) return;
|
|
320
|
+
flat.push({
|
|
321
|
+
id: t.id,
|
|
322
|
+
name: t.name,
|
|
323
|
+
description: t.description,
|
|
324
|
+
input_schema: t.input_schema
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
remoteTools = flat;
|
|
329
|
+
if (toolsCountEl) {
|
|
330
|
+
toolsCountEl.textContent = flat.length;
|
|
331
|
+
toolsCountEl.classList.toggle("lmw-tools-count-zero", flat.length === 0);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Reflect the mix of child-tool states onto a server's bulk checkbox
|
|
336
|
+
// (checked / indeterminate / unchecked). Called after any child
|
|
337
|
+
// checkbox change, and after applying a bulk toggle.
|
|
338
|
+
function refreshServerBulkCheckbox(server, bulkCheckbox) {
|
|
339
|
+
var tools = server.tools || [];
|
|
340
|
+
var n = tools.length;
|
|
341
|
+
if (n === 0) { bulkCheckbox.checked = false; bulkCheckbox.indeterminate = false; return; }
|
|
342
|
+
var onCount = 0;
|
|
343
|
+
tools.forEach(function(t) { if (selectedToolIds.has(t.id)) onCount++; });
|
|
344
|
+
bulkCheckbox.checked = (onCount === n);
|
|
345
|
+
bulkCheckbox.indeterminate = (onCount > 0 && onCount < n);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function loadHubResourcesForPickers() {
|
|
349
|
+
// Called once on first widget open (idempotent — pickerLoaded flag
|
|
350
|
+
// below). Fetches models + MCP servers from the hub's anon endpoints
|
|
351
|
+
// and populates each picker. Non-fatal if either fetch fails (widget
|
|
352
|
+
// still works with the initial `model:` + no hub tools).
|
|
353
|
+
var tasks = [];
|
|
354
|
+
|
|
355
|
+
if (ENABLE_MODEL_PICKER && modelPicker && LLM_PROVIDER === "ollama") {
|
|
356
|
+
// No hub to ask: Ollama lists its own models.
|
|
357
|
+
tasks.push(fetchOllamaModels({ baseUrl: LLM_BASE }).then(function(names) {
|
|
358
|
+
var flat = names
|
|
359
|
+
.filter(function(n) { return anyAllowedByAllowlist(n, MODEL_ALLOWLIST); })
|
|
360
|
+
.map(function(n) { return { value: n, label: n }; });
|
|
361
|
+
if (!flat.some(function(m) { return m.value === MODEL; })) {
|
|
362
|
+
flat.unshift({ value: MODEL, label: MODEL });
|
|
363
|
+
}
|
|
364
|
+
modelPicker.innerHTML = "";
|
|
365
|
+
flat.forEach(function(m) {
|
|
366
|
+
var opt = document.createElement("option");
|
|
367
|
+
opt.value = m.value;
|
|
368
|
+
opt.textContent = m.label;
|
|
369
|
+
if (m.value === MODEL) opt.selected = true;
|
|
370
|
+
modelPicker.appendChild(opt);
|
|
371
|
+
});
|
|
372
|
+
if (flat.length > 1) modelPicker.style.display = "";
|
|
373
|
+
}));
|
|
374
|
+
} else if (ENABLE_MODEL_PICKER && modelPicker) {
|
|
375
|
+
tasks.push(fetch(LLM_BASE + "/api/llms", { headers: { "Accept": "application/json" } })
|
|
376
|
+
.then(function(r) { return r.ok ? r.json() : { llms: [] }; })
|
|
377
|
+
.then(function(payload) {
|
|
378
|
+
// /api/llms is heterogeneous per family:
|
|
379
|
+
// - OpenAI/Anthropic/Google: `models: [{ name, display_name, ... }]`
|
|
380
|
+
// - Ollama: `available_models: [{ value, label, ... }]`
|
|
381
|
+
// Anon widget uses api_key_uuid = "ollama-local" so only Ollama models
|
|
382
|
+
// are invocable; filter to that family and use its subshape.
|
|
383
|
+
var flat = [];
|
|
384
|
+
(payload.llms || []).forEach(function(llm) {
|
|
385
|
+
if (llm.family !== "ollama") return;
|
|
386
|
+
(llm.available_models || []).forEach(function(m) {
|
|
387
|
+
if (anyAllowedByAllowlist(m.value, MODEL_ALLOWLIST)) {
|
|
388
|
+
flat.push({ value: m.value, label: m.label });
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
// Ensure the host-configured MODEL is in the list even if the
|
|
393
|
+
// server doesn't return it — visitor should always see the
|
|
394
|
+
// current selection, and we don't want to silently switch.
|
|
395
|
+
if (!flat.some(function(m) { return m.value === MODEL; })) {
|
|
396
|
+
flat.unshift({ value: MODEL, label: MODEL });
|
|
397
|
+
}
|
|
398
|
+
modelPicker.innerHTML = "";
|
|
399
|
+
flat.forEach(function(m) {
|
|
400
|
+
var opt = document.createElement("option");
|
|
401
|
+
opt.value = m.value;
|
|
402
|
+
opt.textContent = m.label;
|
|
403
|
+
if (m.value === MODEL) opt.selected = true;
|
|
404
|
+
modelPicker.appendChild(opt);
|
|
405
|
+
});
|
|
406
|
+
if (flat.length > 1) modelPicker.style.display = "";
|
|
407
|
+
})
|
|
408
|
+
.catch(function(e) { console.warn("[widget] model list fetch failed:", e); }));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (ENABLE_TOOL_PICKER && toolsListEl) {
|
|
412
|
+
tasks.push(fetch(TOOL_HUB_BASE + "/api/mcp_servers", { headers: { "Accept": "application/json" } })
|
|
413
|
+
.then(function(r) { return r.ok ? r.json() : { mcp_servers: [] }; })
|
|
414
|
+
.then(function(payload) {
|
|
415
|
+
hubMcpServers = (payload.mcp_servers || []).filter(function(s) {
|
|
416
|
+
return anyAllowedByAllowlist(s.name, HUB_TOOLS_ALLOWLIST);
|
|
417
|
+
});
|
|
418
|
+
toolsListEl.innerHTML = "";
|
|
419
|
+
if (hubMcpServers.length === 0) {
|
|
420
|
+
var empty = document.createElement("div");
|
|
421
|
+
empty.className = "lmw-tools-empty";
|
|
422
|
+
empty.textContent = "No hub-registered tools available.";
|
|
423
|
+
toolsListEl.appendChild(empty);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
hubMcpServers.forEach(function(s) {
|
|
427
|
+
var serverBlock = document.createElement("div");
|
|
428
|
+
serverBlock.className = "lmw-tools-server";
|
|
429
|
+
|
|
430
|
+
// Server header row: bulk checkbox + name + tool count + expand caret.
|
|
431
|
+
// Bulk checkbox toggles ALL of the server's tools at once and reflects
|
|
432
|
+
// the mix of child states (indeterminate when partial).
|
|
433
|
+
var headerRow = document.createElement("div");
|
|
434
|
+
headerRow.className = "lmw-tools-server-row";
|
|
435
|
+
|
|
436
|
+
var bulkCb = document.createElement("input");
|
|
437
|
+
bulkCb.type = "checkbox";
|
|
438
|
+
bulkCb.className = "lmw-tools-server-bulk";
|
|
439
|
+
bulkCb.title = "Enable all tools on this server";
|
|
440
|
+
bulkCb.addEventListener("click", function(e) {
|
|
441
|
+
// Prevent header-row click from toggling expand.
|
|
442
|
+
e.stopPropagation();
|
|
443
|
+
});
|
|
444
|
+
bulkCb.addEventListener("change", function() {
|
|
445
|
+
var shouldOn = bulkCb.checked;
|
|
446
|
+
(s.tools || []).forEach(function(t) {
|
|
447
|
+
if (shouldOn) selectedToolIds.add(t.id);
|
|
448
|
+
else selectedToolIds.delete(t.id);
|
|
449
|
+
});
|
|
450
|
+
bulkCb.indeterminate = false;
|
|
451
|
+
// Sync every visible individual checkbox for this server.
|
|
452
|
+
childList.querySelectorAll('input[type="checkbox"]').forEach(function(cb) {
|
|
453
|
+
cb.checked = shouldOn;
|
|
454
|
+
});
|
|
455
|
+
refreshRemoteToolsFromPicker();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
var nameSpan = document.createElement("span");
|
|
459
|
+
nameSpan.className = "lmw-tools-server-name";
|
|
460
|
+
var toolCount = (s.tools || []).length;
|
|
461
|
+
nameSpan.textContent = s.name + " (" + toolCount + " tool" + (toolCount === 1 ? "" : "s") + ")";
|
|
462
|
+
|
|
463
|
+
var caret = document.createElement("span");
|
|
464
|
+
caret.className = "lmw-tools-server-caret";
|
|
465
|
+
caret.textContent = "▸";
|
|
466
|
+
|
|
467
|
+
headerRow.appendChild(bulkCb);
|
|
468
|
+
headerRow.appendChild(nameSpan);
|
|
469
|
+
headerRow.appendChild(caret);
|
|
470
|
+
|
|
471
|
+
// Individual-tool rows — one checkbox per tool, indented.
|
|
472
|
+
// Hidden by default; header click expands. This mirrors
|
|
473
|
+
// chat.aibranch.org's lazy-expand server UX.
|
|
474
|
+
var childList = document.createElement("div");
|
|
475
|
+
childList.className = "lmw-tools-server-children";
|
|
476
|
+
childList.style.display = "none";
|
|
477
|
+
|
|
478
|
+
(s.tools || []).forEach(function(t) {
|
|
479
|
+
var toolLabel = document.createElement("label");
|
|
480
|
+
toolLabel.className = "lmw-tools-item lmw-tools-item-child";
|
|
481
|
+
toolLabel.title = t.description || t.name;
|
|
482
|
+
var cb = document.createElement("input");
|
|
483
|
+
cb.type = "checkbox";
|
|
484
|
+
cb.value = t.id;
|
|
485
|
+
cb.checked = selectedToolIds.has(t.id);
|
|
486
|
+
cb.addEventListener("change", function() {
|
|
487
|
+
if (cb.checked) selectedToolIds.add(t.id);
|
|
488
|
+
else selectedToolIds.delete(t.id);
|
|
489
|
+
refreshServerBulkCheckbox(s, bulkCb);
|
|
490
|
+
refreshRemoteToolsFromPicker();
|
|
491
|
+
});
|
|
492
|
+
var span = document.createElement("span");
|
|
493
|
+
span.textContent = t.name;
|
|
494
|
+
toolLabel.appendChild(cb);
|
|
495
|
+
toolLabel.appendChild(span);
|
|
496
|
+
childList.appendChild(toolLabel);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
headerRow.addEventListener("click", function() {
|
|
500
|
+
var isOpen = childList.style.display !== "none";
|
|
501
|
+
childList.style.display = isOpen ? "none" : "block";
|
|
502
|
+
caret.textContent = isOpen ? "▸" : "▾";
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
serverBlock.appendChild(headerRow);
|
|
506
|
+
serverBlock.appendChild(childList);
|
|
507
|
+
toolsListEl.appendChild(serverBlock);
|
|
508
|
+
|
|
509
|
+
// Initial bulk-checkbox state (all unchecked at first render).
|
|
510
|
+
refreshServerBulkCheckbox(s, bulkCb);
|
|
511
|
+
});
|
|
512
|
+
refreshRemoteToolsFromPicker();
|
|
513
|
+
})
|
|
514
|
+
.catch(function(e) { console.warn("[widget] MCP server list fetch failed:", e); }));
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
await Promise.all(tasks);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (modelPicker) {
|
|
521
|
+
modelPicker.addEventListener("change", function() { MODEL = modelPicker.value; });
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Close the tools dropdown on any click outside its area. Native
|
|
525
|
+
// <details> only closes on summary re-click; visitors expect a
|
|
526
|
+
// dropdown/popover to dismiss on outside click, matching native
|
|
527
|
+
// select/menu behavior. `.contains(target)` includes the summary
|
|
528
|
+
// AND the drop-down list (both are descendants of the <details>).
|
|
529
|
+
if (toolsPicker) {
|
|
530
|
+
document.addEventListener("click", function(e) {
|
|
531
|
+
if (!toolsPicker.open) return;
|
|
532
|
+
if (toolsPicker.contains(e.target)) return;
|
|
533
|
+
toolsPicker.open = false;
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
var pickerLoaded = false;
|
|
538
|
+
async function ensurePickerLoaded() {
|
|
539
|
+
if (pickerLoaded) return;
|
|
540
|
+
pickerLoaded = true; // set first so parallel opens don't double-fetch
|
|
541
|
+
try { await loadHubResourcesForPickers(); }
|
|
542
|
+
catch (e) { pickerLoaded = false; throw e; }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Fetch well-known MCP manifests at boot. Auto-discovers same origin
|
|
546
|
+
// if WELL_KNOWN_URLS is null; empty array disables entirely.
|
|
547
|
+
var hostWideTools = [];
|
|
548
|
+
var hostWidePrompts = [];
|
|
549
|
+
var resourceContext = null; // payload of the host's reference resource
|
|
550
|
+
var resourcePlan = null; // the pre-flight decision, kept so Clear can re-arm the gate
|
|
551
|
+
|
|
552
|
+
// Beyond ~2k tokens a reference resource starts crowding out the
|
|
553
|
+
// conversation on a 32k-context local model — production PubDictionaries
|
|
554
|
+
// is 218 dictionaries / ~35KB / ~10k tokens.
|
|
555
|
+
var RESOURCE_BUDGET_BYTES = 8000;
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
// Before anything asynchronous: if this page was navigated away from and
|
|
559
|
+
// back, the visitor should see their conversation immediately.
|
|
560
|
+
restoreConversation();
|
|
561
|
+
|
|
562
|
+
var wellKnownReady = (async function() {
|
|
563
|
+
var urls = WELL_KNOWN_URLS === null
|
|
564
|
+
? [ window.location.origin + "/.well-known/mcp.json" ]
|
|
565
|
+
: WELL_KNOWN_URLS;
|
|
566
|
+
for (var i = 0; i < urls.length; i++) {
|
|
567
|
+
var tools = await fetchMcpManifest(urls[i]);
|
|
568
|
+
hostWideTools = hostWideTools.concat(tools);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// The manifest publishes TOOLS only, so the endpoints are learned from
|
|
572
|
+
// them. A server offering only prompts or resources is invisible here —
|
|
573
|
+
// worth knowing when deciding what a static manifest should carry.
|
|
574
|
+
var endpoints = [];
|
|
575
|
+
hostWideTools.forEach(function(t) {
|
|
576
|
+
if (t.endpoint && endpoints.indexOf(t.endpoint) === -1) endpoints.push(t.endpoint);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
for (var j = 0; j < endpoints.length; j++) {
|
|
580
|
+
var endpoint = endpoints[j];
|
|
581
|
+
var prompts = await listMcpPrompts({ endpoint: endpoint });
|
|
582
|
+
hostWidePrompts = hostWidePrompts.concat(prompts);
|
|
583
|
+
|
|
584
|
+
if (resourcePlan === null) {
|
|
585
|
+
// Listing, size gate and read all live in the orchestrator, where
|
|
586
|
+
// they are tested together — a gate nothing consults is exactly
|
|
587
|
+
// the bug this shape prevents.
|
|
588
|
+
var loaded = await loadHostResource({
|
|
589
|
+
endpoint: endpoint,
|
|
590
|
+
budgetBytes: RESOURCE_BUDGET_BYTES,
|
|
591
|
+
list: listMcpResources,
|
|
592
|
+
read: readMcpResource,
|
|
593
|
+
onSkip: function(plan) {
|
|
594
|
+
console.info("[llm_meta_widget] not attaching " + plan.uri +
|
|
595
|
+
" (" + plan.reason + ", sizeBytes=" + plan.sizeBytes + ")");
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
// A volatile resource comes back with no context — it is read
|
|
599
|
+
// per turn instead — so the plan, not the payload, is what says
|
|
600
|
+
// whether this endpoint offered anything worth attaching.
|
|
601
|
+
if (loaded && loaded.plan && loaded.plan.fetch) {
|
|
602
|
+
resourceContext = loaded.context;
|
|
603
|
+
resourcePlan = loaded.plan;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
renderPromptButtons();
|
|
608
|
+
renderWelcome();
|
|
609
|
+
})();
|
|
610
|
+
|
|
611
|
+
// ---- server-offered prompt templates --------------------------------
|
|
612
|
+
//
|
|
613
|
+
// One button per prompt the host's MCP server offers. Clicking it fills
|
|
614
|
+
// the prompt's arguments from page state, asks the server to materialise
|
|
615
|
+
// the message (prompts/get), then drops the text into the textarea and
|
|
616
|
+
// submits — so it travels the exact path a typed message does.
|
|
617
|
+
|
|
618
|
+
var promptsEl = root.querySelector(".lmw-prompts");
|
|
619
|
+
|
|
620
|
+
function renderPromptButtons() {
|
|
621
|
+
if (!promptsEl) return;
|
|
622
|
+
promptsEl.textContent = "";
|
|
623
|
+
if (!hostWidePrompts.length) { promptsEl.style.display = "none"; return; }
|
|
624
|
+
|
|
625
|
+
hostWidePrompts.forEach(function(prompt) {
|
|
626
|
+
var props = promptButtonProps(prompt);
|
|
627
|
+
var button = document.createElement("button");
|
|
628
|
+
button.type = "button";
|
|
629
|
+
button.className = "lmw-prompt";
|
|
630
|
+
button.textContent = props.label;
|
|
631
|
+
button.title = props.title;
|
|
632
|
+
button.addEventListener("click", function() { runPromptTemplate(prompt, button); });
|
|
633
|
+
promptsEl.appendChild(button);
|
|
634
|
+
});
|
|
635
|
+
promptsEl.style.display = "";
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Put a saved transcript back on screen: the same bubbles, the same
|
|
639
|
+
// markdown, and the same record of which tools ran. Called once at boot,
|
|
640
|
+
// before the welcome block, which is only for a genuinely fresh start.
|
|
641
|
+
function restoreConversation() {
|
|
642
|
+
var saved = conversationStore.load();
|
|
643
|
+
if (!saved || !saved.turns.length) return false;
|
|
644
|
+
|
|
645
|
+
conversation = saved.turns;
|
|
646
|
+
saved.turns.forEach(function(turn) {
|
|
647
|
+
var body = appendTurn(turn.role, turn.role === "assistant" ? "" : turn.content);
|
|
648
|
+
if (turn.role === "assistant") renderMarkdownInto(body, turn.content || "");
|
|
649
|
+
if (!turn.tools || !turn.tools.length) return;
|
|
650
|
+
var row = document.createElement("div");
|
|
651
|
+
row.className = "lmw-tool-chips";
|
|
652
|
+
turn.tools.forEach(function(tool) {
|
|
653
|
+
var chip = document.createElement("span");
|
|
654
|
+
chip.className = "lmw-tool-chip" + (tool.error ? " error" : "");
|
|
655
|
+
chip.textContent = (tool.error ? "❌ " : "🔧 ") + tool.name;
|
|
656
|
+
row.appendChild(chip);
|
|
657
|
+
});
|
|
658
|
+
if (body.parentNode) body.parentNode.appendChild(row);
|
|
659
|
+
});
|
|
660
|
+
historyEl.scrollTop = historyEl.scrollHeight;
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// A blank panel tells a first-time visitor nothing. Open with a greeting
|
|
665
|
+
// and the offers themselves — each template showing what it will take from
|
|
666
|
+
// the page and what it will ask for — so the assistant is the page's way
|
|
667
|
+
// in rather than a box you must already know how to talk to.
|
|
668
|
+
function renderWelcome() {
|
|
669
|
+
// A restored transcript is not a fresh start; the greeting would push
|
|
670
|
+
// the conversation the visitor is mid-way through off the screen.
|
|
671
|
+
if (!historyEl || historyEl.querySelector(".message")) return;
|
|
672
|
+
historyEl.textContent = "";
|
|
673
|
+
|
|
674
|
+
var box = document.createElement("div");
|
|
675
|
+
box.className = "lmw-welcome";
|
|
676
|
+
|
|
677
|
+
var hello = document.createElement("p");
|
|
678
|
+
hello.className = "lmw-welcome-hello";
|
|
679
|
+
hello.textContent = GREETING ||
|
|
680
|
+
"Hi — I can work this page for you. Tell me what you need in your own words" +
|
|
681
|
+
(hostWidePrompts.length ? ", or start with one of these:" : ".");
|
|
682
|
+
box.appendChild(hello);
|
|
683
|
+
|
|
684
|
+
var state = window[STATE_GLOBAL] || {};
|
|
685
|
+
hostWidePrompts.forEach(function(prompt) {
|
|
686
|
+
var props = promptButtonProps(prompt);
|
|
687
|
+
var card = document.createElement("div");
|
|
688
|
+
card.className = "lmw-welcome-card";
|
|
689
|
+
|
|
690
|
+
var start = document.createElement("button");
|
|
691
|
+
start.type = "button";
|
|
692
|
+
start.className = "lmw-welcome-start";
|
|
693
|
+
start.textContent = props.label;
|
|
694
|
+
start.addEventListener("click", function() { runPromptTemplate(prompt, start); });
|
|
695
|
+
card.appendChild(start);
|
|
696
|
+
|
|
697
|
+
if (prompt.description) {
|
|
698
|
+
var why = document.createElement("p");
|
|
699
|
+
why.className = "lmw-welcome-why";
|
|
700
|
+
why.textContent = prompt.description;
|
|
701
|
+
card.appendChild(why);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Name the placeholders, filled or not, so the offer is concrete:
|
|
705
|
+
// a newcomer can see the text box is empty before clicking.
|
|
706
|
+
var summary = promptArgumentSummary(prompt, state);
|
|
707
|
+
if (summary.length) {
|
|
708
|
+
var slots = document.createElement("ul");
|
|
709
|
+
slots.className = "lmw-welcome-slots";
|
|
710
|
+
summary.forEach(function(slot) {
|
|
711
|
+
var li = document.createElement("li");
|
|
712
|
+
li.className = slot.filled ? "filled" : "empty";
|
|
713
|
+
li.textContent = slot.filled
|
|
714
|
+
? slot.name + ": " + (slot.value.length > 60 ? slot.value.slice(0, 60) + "…" : slot.value)
|
|
715
|
+
: slot.name + ": not set yet — I'll ask, or work it out";
|
|
716
|
+
slots.appendChild(li);
|
|
717
|
+
});
|
|
718
|
+
card.appendChild(slots);
|
|
719
|
+
}
|
|
720
|
+
box.appendChild(card);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
historyEl.appendChild(box);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async function runPromptTemplate(prompt, button) {
|
|
727
|
+
var resolved = resolvePromptArguments(prompt, window[STATE_GLOBAL] || {});
|
|
728
|
+
var args = resolved.args;
|
|
729
|
+
var missing = resolved.missing;
|
|
730
|
+
|
|
731
|
+
// Say which page field is empty rather than letting the server answer
|
|
732
|
+
// -32602 for something the user can actually fix on screen.
|
|
733
|
+
if (missing.length) {
|
|
734
|
+
appendTurn("error", "Cannot run \"" + (prompt.title || prompt.name) + "\" yet — nothing to use for: " +
|
|
735
|
+
missing.join(", ") + ". Fill that in on the page first.");
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
button.disabled = true;
|
|
740
|
+
try {
|
|
741
|
+
var result = await getMcpPrompt({ endpoint: prompt.endpoint, name: prompt.name, args: args });
|
|
742
|
+
var text = promptMessagesToText(result);
|
|
743
|
+
if (!text) { appendTurn("error", "The server returned an empty prompt."); return; }
|
|
744
|
+
inputEl.value = text;
|
|
745
|
+
pendingTurnLabel = promptButtonProps(prompt).label;
|
|
746
|
+
if (typeof formEl.requestSubmit === "function") formEl.requestSubmit();
|
|
747
|
+
else formEl.dispatchEvent(new Event("submit", { cancelable: true }));
|
|
748
|
+
} catch (e) {
|
|
749
|
+
appendTurn("error", "Prompt failed: " + (e && e.message ? e.message : e));
|
|
750
|
+
} finally {
|
|
751
|
+
button.disabled = false;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Set just before a template submits, so its turn is labelled by what the
|
|
756
|
+
// user actually did — "Annotate text" — instead of showing a paragraph of
|
|
757
|
+
// server-written instructions as though they had typed it. The instructions
|
|
758
|
+
// stay one click away rather than hidden: what was sent is what is shown.
|
|
759
|
+
var pendingTurnLabel = null;
|
|
760
|
+
|
|
761
|
+
function appendTurn(role, text, turnLabel) {
|
|
762
|
+
// Class names mirror llm_meta_chat's chats/_message.html.erb —
|
|
763
|
+
// `.message.<role>`, `.message-role`, `.message-content` — so the
|
|
764
|
+
// shared conversation.css styles apply directly (see the <link>
|
|
765
|
+
// above). The .lmw-* prefix is reserved for widget-CHROME classes
|
|
766
|
+
// (header, clear button, scroll region, input area) that aren't
|
|
767
|
+
// part of the shared conversation surface.
|
|
768
|
+
var welcome = historyEl.querySelector(".lmw-welcome");
|
|
769
|
+
if (welcome) welcome.remove();
|
|
770
|
+
|
|
771
|
+
var div = document.createElement("div");
|
|
772
|
+
div.className = "message " + role;
|
|
773
|
+
var label = document.createElement("div");
|
|
774
|
+
label.className = "message-role";
|
|
775
|
+
label.textContent = roleLabel(role);
|
|
776
|
+
var body = document.createElement("div");
|
|
777
|
+
body.className = "message-content";
|
|
778
|
+
// User / system / error turns render as plain text (safe from any
|
|
779
|
+
// injection via user input or server-supplied strings). Assistant
|
|
780
|
+
// content is rendered as markdown but only after the assistant's
|
|
781
|
+
// text is streamed in via renderMarkdownInto — this appendTurn
|
|
782
|
+
// creates the empty container.
|
|
783
|
+
if (turnLabel) {
|
|
784
|
+
var details = document.createElement("details");
|
|
785
|
+
var summary = document.createElement("summary");
|
|
786
|
+
summary.textContent = turnLabel;
|
|
787
|
+
var full = document.createElement("div");
|
|
788
|
+
full.className = "lmw-sent-text";
|
|
789
|
+
full.textContent = text;
|
|
790
|
+
details.appendChild(summary);
|
|
791
|
+
details.appendChild(full);
|
|
792
|
+
body.appendChild(details);
|
|
793
|
+
} else {
|
|
794
|
+
body.textContent = text;
|
|
795
|
+
}
|
|
796
|
+
div.appendChild(label);
|
|
797
|
+
div.appendChild(document.createTextNode(" "));
|
|
798
|
+
div.appendChild(body);
|
|
799
|
+
historyEl.appendChild(div);
|
|
800
|
+
historyEl.scrollTop = historyEl.scrollHeight;
|
|
801
|
+
body.roleLabel = label; // so a turn in flight can show it is working
|
|
802
|
+
return body;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Render a raw-markdown buffer into an element's innerHTML. marked's
|
|
806
|
+
// default HTML output escapes user content, so passing arbitrary
|
|
807
|
+
// LLM output is safe against XSS. Called per-delta so streaming
|
|
808
|
+
// assistant text formats live (headings/lists/code blocks/etc.).
|
|
809
|
+
function renderMarkdownInto(el, mdText) {
|
|
810
|
+
try { el.innerHTML = marked.parse(mdText); }
|
|
811
|
+
catch (e) { el.textContent = mdText; } // fall back to raw on parse error
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Role labels mirror llm_meta_chat's chats/_message.html.erb:
|
|
815
|
+
// user → "👤 You"
|
|
816
|
+
// assistant → "🤖 <model>"
|
|
817
|
+
// so the widget reads as the same UI as the full chat host.
|
|
818
|
+
function roleLabel(role) {
|
|
819
|
+
if (role === "user") return "👤 You";
|
|
820
|
+
if (role === "assistant") return "🤖 " + MODEL;
|
|
821
|
+
if (role === "system") return "• system";
|
|
822
|
+
if (role === "error") return "⚠ error";
|
|
823
|
+
return role;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// While a turn is in flight the assistant's label becomes a turning gear.
|
|
827
|
+
// Same markup and class names as llm_meta_chat's message_stream_controller,
|
|
828
|
+
// so the shared conversation.css drives both. Without it there is no sign
|
|
829
|
+
// whether the assistant is still working or has quietly stopped — and on a
|
|
830
|
+
// local model a tool round can take a long time before the first token.
|
|
831
|
+
function markWorking(label) {
|
|
832
|
+
if (!label || label.classList.contains("is-working")) return;
|
|
833
|
+
label.innerHTML = '<span class="role-spinner" aria-hidden="true">\u2699\uFE0F</span> Working…';
|
|
834
|
+
label.classList.add("is-working");
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function markDone(label) {
|
|
838
|
+
if (!label || !label.classList.contains("is-working")) return;
|
|
839
|
+
label.classList.remove("is-working");
|
|
840
|
+
label.textContent = roleLabel("assistant");
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Tool chips, written as the tools run rather than assembled at the end.
|
|
844
|
+
// The end-of-turn footer was invisible for as long as the turn lasted —
|
|
845
|
+
// minutes on a thinking model — so a visitor watched tools execute with
|
|
846
|
+
// no idea which ones.
|
|
847
|
+
var liveChips = null; // the container inside the current bubble
|
|
848
|
+
var pendingChips = []; // chips awaiting their outcome, oldest first
|
|
849
|
+
|
|
850
|
+
function chipRow() {
|
|
851
|
+
if (liveChips && liveChips.parentNode) return liveChips;
|
|
852
|
+
if (!activeAssistantBody || !activeAssistantBody.parentNode) return null;
|
|
853
|
+
liveChips = document.createElement("div");
|
|
854
|
+
liveChips.className = "lmw-tool-chips";
|
|
855
|
+
// Inside the bubble but OUTSIDE .message-content: every text delta
|
|
856
|
+
// re-renders that element's markdown from scratch, which silently
|
|
857
|
+
// erased any chip already written into it.
|
|
858
|
+
activeAssistantBody.parentNode.appendChild(liveChips);
|
|
859
|
+
return liveChips;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function announceToolCall(toolCall) {
|
|
863
|
+
var row = chipRow();
|
|
864
|
+
if (!row) return;
|
|
865
|
+
var chip = document.createElement("span");
|
|
866
|
+
chip.className = "lmw-tool-chip running";
|
|
867
|
+
chip.textContent = "⏳ " + toolCall.name;
|
|
868
|
+
chip.dataset.tool = toolCall.name;
|
|
869
|
+
row.appendChild(chip);
|
|
870
|
+
pendingChips.push(chip);
|
|
871
|
+
historyEl.scrollTop = historyEl.scrollHeight;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function resolveToolCall(outcome) {
|
|
875
|
+
// Match by name, oldest first: ids are not always echoed back, and a
|
|
876
|
+
// repeated name resolves in call order.
|
|
877
|
+
var idx = pendingChips.findIndex(function(c) { return c.dataset.tool === outcome.toolCall.name; });
|
|
878
|
+
var chip = idx === -1 ? null : pendingChips.splice(idx, 1)[0];
|
|
879
|
+
if (!chip) return;
|
|
880
|
+
chip.className = "lmw-tool-chip" + (outcome.error ? " error" : "");
|
|
881
|
+
chip.textContent = (outcome.error ? "❌ " : "🔧 ") + outcome.toolCall.name;
|
|
882
|
+
if (outcome.error) chip.title = outcome.error.message || String(outcome.error);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
var currentThinkingBlock = null;
|
|
886
|
+
var currentThinkingBody = null;
|
|
887
|
+
// The assistant bubble of the turn in flight. Reasoning belongs ABOVE that
|
|
888
|
+
// bubble's content, the way llm_meta_chat places it — appending it to the
|
|
889
|
+
// transcript instead left it below the answer it was reasoning towards.
|
|
890
|
+
var activeAssistantBody = null;
|
|
891
|
+
|
|
892
|
+
function ensureThinkingBlock() {
|
|
893
|
+
if (currentThinkingBody) return currentThinkingBody;
|
|
894
|
+
// Class names match llm_meta_chat's .message-thinking pattern so
|
|
895
|
+
// the shared conversation.css applies directly.
|
|
896
|
+
var details = document.createElement("details");
|
|
897
|
+
details.className = "message-thinking";
|
|
898
|
+
details.open = true;
|
|
899
|
+
details.classList.add("thinking-active");
|
|
900
|
+
var summary = document.createElement("summary");
|
|
901
|
+
summary.textContent = "🤔 thinking…";
|
|
902
|
+
var dots = document.createElement("span");
|
|
903
|
+
dots.className = "thinking-dots";
|
|
904
|
+
for (var i = 0; i < 3; i++) {
|
|
905
|
+
var dot = document.createElement("span");
|
|
906
|
+
dot.textContent = ".";
|
|
907
|
+
dots.appendChild(dot);
|
|
908
|
+
}
|
|
909
|
+
summary.appendChild(dots);
|
|
910
|
+
var body = document.createElement("div");
|
|
911
|
+
body.className = "message-thinking-content";
|
|
912
|
+
details.appendChild(summary);
|
|
913
|
+
details.appendChild(body);
|
|
914
|
+
if (activeAssistantBody && activeAssistantBody.parentNode) {
|
|
915
|
+
activeAssistantBody.parentNode.insertBefore(details, activeAssistantBody);
|
|
916
|
+
} else {
|
|
917
|
+
historyEl.appendChild(details);
|
|
918
|
+
}
|
|
919
|
+
historyEl.scrollTop = historyEl.scrollHeight;
|
|
920
|
+
currentThinkingBlock = details;
|
|
921
|
+
currentThinkingBody = body;
|
|
922
|
+
return body;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function collapseThinkingBlock() {
|
|
926
|
+
if (currentThinkingBlock) {
|
|
927
|
+
currentThinkingBlock.classList.remove("thinking-active");
|
|
928
|
+
currentThinkingBlock.open = false;
|
|
929
|
+
var summary = currentThinkingBlock.querySelector("summary");
|
|
930
|
+
if (summary) summary.textContent = "🤔 thinking (finished)";
|
|
931
|
+
}
|
|
932
|
+
currentThinkingBlock = null;
|
|
933
|
+
currentThinkingBody = null;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function currentPageState() {
|
|
937
|
+
var reader = window[STATE_GLOBAL] || {};
|
|
938
|
+
var out = {};
|
|
939
|
+
Object.keys(reader).forEach(function(k) {
|
|
940
|
+
try { out[k] = reader[k](); } catch (e) { out[k] = "<error: " + e.message + ">"; }
|
|
941
|
+
});
|
|
942
|
+
return out;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function currentSystemPrompt(resourceLines) {
|
|
946
|
+
return [
|
|
947
|
+
"You are integrated into a web page as an AI assistant. You have tools available to change page state or fetch information.",
|
|
948
|
+
"",
|
|
949
|
+
"RULES for tool use:",
|
|
950
|
+
"1. If the user's question can be answered from the Current page state below, answer directly with a plain-text response — do NOT invoke a tool.",
|
|
951
|
+
"2. If the user requests a state change, or needs information not in the page state, invoke the matching tool via a function call. Do not describe your intent in text without actually invoking (a textual promise like \"I will add X\" is a failure).",
|
|
952
|
+
"3. After a tool returns a result, use it: either take the next step the task needs, or — if the task is done — answer in plain text. Do not stop silently after a tool call.",
|
|
953
|
+
"4. NEVER repeat a call you have already made with the same or similar arguments — its result is already in the conversation history.",
|
|
954
|
+
"",
|
|
955
|
+
"Current page state:",
|
|
956
|
+
JSON.stringify(currentPageState(), null, 2)
|
|
957
|
+
].concat(resourceLines || []).join("\n");
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Runs once per send, before the system prompt is built: a volatile
|
|
961
|
+
// resource is re-read here, a stable one re-uses the copy taken at boot.
|
|
962
|
+
// Either way it is attached to every turn — the server's hint governs
|
|
963
|
+
// re-FETCHING; how often to include it is this client's call.
|
|
964
|
+
async function resourceLinesForThisTurn() {
|
|
965
|
+
var turn = await resourceLinesForTurn({
|
|
966
|
+
plan: resourcePlan,
|
|
967
|
+
cached: resourceContext,
|
|
968
|
+
endpoint: resourcePlan && resourcePlan.endpoint,
|
|
969
|
+
read: readMcpResource,
|
|
970
|
+
budgetBytes: RESOURCE_BUDGET_BYTES
|
|
971
|
+
});
|
|
972
|
+
resourceContext = turn.cached;
|
|
973
|
+
return turn.lines;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Per-turn AbortController — lets the Clear button (or a new submit)
|
|
977
|
+
// terminate an in-flight runChatLoop.
|
|
978
|
+
var currentAbort = null;
|
|
979
|
+
|
|
980
|
+
clearBtn.addEventListener("click", function() {
|
|
981
|
+
if (currentAbort) { try { currentAbort.abort(); } catch (e) { /* noop */ } }
|
|
982
|
+
conversation = [];
|
|
983
|
+
conversationStore.clear();
|
|
984
|
+
historyEl.innerHTML = "";
|
|
985
|
+
renderWelcome();
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
// Enter submits, Shift+Enter inserts a newline — matches llm_meta_chat's
|
|
989
|
+
// input UX so users can type multi-line prompts without accidentally
|
|
990
|
+
// firing the form.
|
|
991
|
+
inputEl.addEventListener("keydown", function(e) {
|
|
992
|
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
|
993
|
+
e.preventDefault();
|
|
994
|
+
if (typeof formEl.requestSubmit === "function") formEl.requestSubmit();
|
|
995
|
+
else formEl.dispatchEvent(new Event("submit", { cancelable: true }));
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
formEl.addEventListener("submit", async function(event) {
|
|
1000
|
+
event.preventDefault();
|
|
1001
|
+
var userText = inputEl.value.trim();
|
|
1002
|
+
if (!userText) return;
|
|
1003
|
+
inputEl.value = "";
|
|
1004
|
+
appendTurn("user", userText, pendingTurnLabel);
|
|
1005
|
+
pendingTurnLabel = null;
|
|
1006
|
+
|
|
1007
|
+
if (currentAbort) { try { currentAbort.abort(); } catch (e) { /* noop */ } }
|
|
1008
|
+
currentAbort = new AbortController();
|
|
1009
|
+
|
|
1010
|
+
var assistantBody = appendTurn("assistant", "");
|
|
1011
|
+
activeAssistantBody = assistantBody;
|
|
1012
|
+
liveChips = null;
|
|
1013
|
+
pendingChips = [];
|
|
1014
|
+
markWorking(assistantBody.roleLabel);
|
|
1015
|
+
var assistantMarkdown = ""; // accumulate raw markdown, re-render on each delta
|
|
1016
|
+
|
|
1017
|
+
try {
|
|
1018
|
+
// Discovery has to finish before the system prompt is built: the
|
|
1019
|
+
// resource catalog is attached to the first turn only, so building
|
|
1020
|
+
// messages first would silently ship that turn without it AND mark
|
|
1021
|
+
// it as sent.
|
|
1022
|
+
await wellKnownReady;
|
|
1023
|
+
|
|
1024
|
+
var resourceLines = await resourceLinesForThisTurn();
|
|
1025
|
+
var messages = [{ role: "system", content: currentSystemPrompt(resourceLines) }]
|
|
1026
|
+
.concat(conversation.map(function(t) { return { role: t.role, content: t.content }; }))
|
|
1027
|
+
.concat([{ role: "user", content: userText }]);
|
|
1028
|
+
|
|
1029
|
+
var result = await runChatLoop({
|
|
1030
|
+
baseUrl: LLM_BASE,
|
|
1031
|
+
toolHubUrl: TOOL_HUB_BASE,
|
|
1032
|
+
apiKeyUuid: API_KEY_UUID,
|
|
1033
|
+
modelName: MODEL,
|
|
1034
|
+
messages: messages,
|
|
1035
|
+
localTools: localTools,
|
|
1036
|
+
remoteTools: remoteTools,
|
|
1037
|
+
hostWideTools: hostWideTools,
|
|
1038
|
+
aiActions: window[ACTIONS_GLOBAL] || {},
|
|
1039
|
+
maxRounds: MAX_ROUNDS,
|
|
1040
|
+
provider: LLM_PROVIDER === "ollama" ? "ollama" : "hub",
|
|
1041
|
+
signal: currentAbort.signal,
|
|
1042
|
+
onToolCall: function(toolCall) { announceToolCall(toolCall); },
|
|
1043
|
+
onToolDispatched: function(outcome) { resolveToolCall(outcome); },
|
|
1044
|
+
onPhase: function(name) {
|
|
1045
|
+
// 'thinking' covers the long silence before the first
|
|
1046
|
+
// token; 'responding' means text is on its way.
|
|
1047
|
+
if (name === "responding") markDone(assistantBody.roleLabel);
|
|
1048
|
+
else markWorking(assistantBody.roleLabel);
|
|
1049
|
+
},
|
|
1050
|
+
onRoundStart: function(roundIdx) {
|
|
1051
|
+
// A new round means more work: tool results are going back
|
|
1052
|
+
// to the model, which is the longest wait of all.
|
|
1053
|
+
markWorking(assistantBody.roleLabel);
|
|
1054
|
+
// Loop mechanics are debugging info, not user-facing signal.
|
|
1055
|
+
// Reuse the same assistant bubble across rounds — text just
|
|
1056
|
+
// keeps streaming into it (accumulating markdown). Weaker
|
|
1057
|
+
// models sometimes emit tool_calls in >1 round instead of
|
|
1058
|
+
// synthesizing right away; keeping one bubble makes the
|
|
1059
|
+
// resulting conversation read as ONE reply rather than a
|
|
1060
|
+
// debug trace with round separators.
|
|
1061
|
+
collapseThinkingBlock();
|
|
1062
|
+
},
|
|
1063
|
+
onThinkingDelta: function(delta) {
|
|
1064
|
+
var body = ensureThinkingBlock();
|
|
1065
|
+
body.appendChild(document.createTextNode(delta));
|
|
1066
|
+
body.scrollTop = body.scrollHeight;
|
|
1067
|
+
historyEl.scrollTop = historyEl.scrollHeight;
|
|
1068
|
+
},
|
|
1069
|
+
onTextDelta: function(delta) {
|
|
1070
|
+
markDone(assistantBody.roleLabel);
|
|
1071
|
+
collapseThinkingBlock();
|
|
1072
|
+
assistantMarkdown += delta;
|
|
1073
|
+
renderMarkdownInto(assistantBody, assistantMarkdown);
|
|
1074
|
+
historyEl.scrollTop = historyEl.scrollHeight;
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
conversation.push({ role: "user", content: userText });
|
|
1078
|
+
conversation.push({
|
|
1079
|
+
role: "assistant",
|
|
1080
|
+
content: result.content,
|
|
1081
|
+
// Kept so a restored transcript still shows what ran — losing
|
|
1082
|
+
// that record is what made a navigating action unbearable.
|
|
1083
|
+
tools: result.dispatched.map(function(d) {
|
|
1084
|
+
return d.error ? { name: d.toolCall.name, error: true } : { name: d.toolCall.name };
|
|
1085
|
+
})
|
|
1086
|
+
});
|
|
1087
|
+
persistConversation();
|
|
1088
|
+
|
|
1089
|
+
// Anything still pending never reported an outcome (a class that
|
|
1090
|
+
// does not round-trip, or a dispatch that vanished). Reconcile
|
|
1091
|
+
// from the loop's own record so no chip is left spinning.
|
|
1092
|
+
result.dispatched.forEach(function(d) { resolveToolCall(d); });
|
|
1093
|
+
pendingChips.forEach(function(chip) {
|
|
1094
|
+
chip.className = "lmw-tool-chip";
|
|
1095
|
+
chip.textContent = "🔧 " + chip.dataset.tool;
|
|
1096
|
+
});
|
|
1097
|
+
pendingChips = [];
|
|
1098
|
+
|
|
1099
|
+
// Skipped = LLM tried a tool that doesn't exist. Real signal.
|
|
1100
|
+
if (result.skipped.length > 0) {
|
|
1101
|
+
appendTurn("system", "Not available on this page: " +
|
|
1102
|
+
result.skipped.map(function(t) { return t.name; }).join(", "));
|
|
1103
|
+
}
|
|
1104
|
+
// Loop termination reasons:
|
|
1105
|
+
// "duplicate_tool_calls" — LLM re-called an already-invoked
|
|
1106
|
+
// tool; the earlier round's dispatch already produced the
|
|
1107
|
+
// answer/effect, so we silently absorb this (no user warning).
|
|
1108
|
+
// otherwise (e.g. max_rounds) — the loop hit a real hard cap
|
|
1109
|
+
// that likely truncated the response; user should know.
|
|
1110
|
+
if (result.stopped_reason && result.stopped_reason !== "duplicate_tool_calls") {
|
|
1111
|
+
appendTurn("system",
|
|
1112
|
+
"The assistant reached its tool-use limit before finishing. " +
|
|
1113
|
+
"The reply above may be incomplete — try asking again or rephrasing.");
|
|
1114
|
+
}
|
|
1115
|
+
} catch (err) {
|
|
1116
|
+
if (err.name === "AbortError" || /aborted/i.test(err.message || "")) {
|
|
1117
|
+
appendTurn("system", "⏹ stopped");
|
|
1118
|
+
} else {
|
|
1119
|
+
appendTurn("error", err.message);
|
|
1120
|
+
}
|
|
1121
|
+
} finally {
|
|
1122
|
+
// Whatever happened — answered, aborted, threw, or returned
|
|
1123
|
+
// nothing at all — the turn is over and the label must stop
|
|
1124
|
+
// claiming otherwise. A spinner left running is a worse lie than
|
|
1125
|
+
// no spinner: it says "still working" about a turn that ended.
|
|
1126
|
+
markDone(assistantBody.roleLabel);
|
|
1127
|
+
collapseThinkingBlock();
|
|
1128
|
+
activeAssistantBody = null;
|
|
1129
|
+
currentAbort = null;
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
|